DevGang
Авторизоваться

Написание игры "Жизнь" на JavaScript

Всем привет, в этом посте я собираюсь поделиться с вами тем, как я создал игру "Жизнь", используя JavaScript. Кроме того, я проведу вас через процесс воссоздания этой увлекательной игры и объясню, как я к ней подошел. Я надеюсь, вы найдете эту статью полезной и вдохновляющей. Давайте отправимся в захватывающее путешествие в увлекательное царство игры "Жизнь"!

Игра "Жизнь"

Игру "Жизнь" разработал британский математик Джон Хортон Конвей в 1970 году. Это игра с нулевым участием игроков, что означает, что ее эволюция определяется ее начальным состоянием и не требует дополнительных вводимых данных. Человек взаимодействует с Game of Life, создавая начальную конфигурацию и наблюдая, как она развивается.Подробнее на wiki

Игра следует четырем правилам, которые определяют ее развитие. Давайте углубимся в правила:

  1. Любая живая ячейка, имеющая менее двух живых соседей, умирает, как бы из-за недостаточного заселения.
  2. Любая живая ячейка с двумя или тремя живыми соседями живет до следующего поколения.
  3. Любая живая ячейка с более чем тремя живыми соседями умирает, как бы из-за перенаселения.
  4. Любая мертвая ячейка, имеющая ровно трех живых соседей, становится живой ячейкой, как бы путем размножения.

Эти правила диктуют эволюцию игры и определяют судьбу каждой ячейки в каждом поколении.

Для этого задания я собираюсь использовать холст для рисования и ячейки. Для обработки, пожалуйста, убедитесь, что у вас на веб-странице есть элемент canvas.

<canvas id="game-board"></canvas>

Если вы ничего не знаете об API Canvas для продолжения чтения этой статьи, я рекомендую изучить это на MDN. Вкратце:

Canvas API предоставляет средства для рисования графики с помощью JavaScript и элемента HTML <canvas>.

Я решил использовать модули ES6 и ванильный JavaScript для этого игрового проекта. На этот раз я сделал сознательный выбор использовать классы ES6 вместо функций.

Тем не менее, не стесняйтесь изучать модули ES6 или любой существующий шаблон, который соответствует вашим потребностям. Вы даже можете выбрать TypeScript для написания проекта, чтобы получить другой опыт или предпочесть функции классам.

Чтобы использовать ключевые слова export и import в ваших файлах JavaScript в качестве модулей ES6, вам необходимо включить их в свой HTML-документ точно так же, как обычные файлы JavaScript. Однако есть одно ключевое отличие. Вы должны использовать атрибут type со значением module в атрибуте HTML.

<canvas id="game-board"></canvas>

<!-- do this for each module -->
<script src="./main.js" type="module"></script>

Я начну с написания кода с определениями констант. Эти константы будут использоваться для указания размера ячейки, ширины и высоты игрового поля, а также цветов ячейки и игрового поля.

export const CELL_SIZE = 10

export const BOARD_WIDTH = document.documentElement.offsetWidth
export const BOARD_HEIGHT = document.documentElement.offsetHeight

export const CELL_COLOR = "rgb(0, 255, 0)"
export const BOARD_COLOR = "rgb(0, 0, 0)"

Используя константы, я могу легко настроить свою игру, как вы уже знаете. Первым шагом является получение canvas элемента. В main.js вы можете получить canvas.

const canvas = document.getElementById("game-board")

Давайте рассмотрим игровые процессы. Исходя из правил игры, вам необходимо иметь начальное состояние игры. Для достижения этого вам потребуется игровое поле и ячейки. В main.js файле вы можете инициализировать и запустить игру, выполнив следующие действия: нарисуйте фон игрового поля, инициализируйте ячейки и, наконец, запустите игру.

Как вы могли догадаться, что я имел в виду три класса и их методы. Давайте раскроем детали! Следующие классы необходимы для нашей игры:

  • Game класс. Этот класс обрабатывает рисование ячеек и игрового поля и управление ими.
  • Board класс. С помощью этого класса мы можем получать параметры игры и рисовать игровое поле
  • Cell класс. Этот класс отвечает за рисование текущего и следующего поколения ячеек.

Чтобы запустить игру, все, что нам нужно, это экземпляр класса Game, и мы можем инициализировать его, когда будем готовы.

Поскольку у нас уже есть элемент canvas, давайте позаботимся обо всем остальном.

import { Game } from "./modules/game.mjs"

const canvas = document.getElementById("game-board")

const game = new Game(canvas)

game.initialize()

Это подготавливает почву для самой сложной части нашего приключения. Я надеюсь, что вы продолжите это путешествие со мной до конца.

Вступление

Пока я размышлял, с чего начать, я остановился на описании Board класса для начала. Не только потому, что этот класс проще, но и потому, что он формирует фундаментальный уровень нашей игры.

Класс Board

Я использую частные свойства для констант внутри классов, которые используются исключительно внутри этих классов. Вы увидите это в каждом из них.

Вкратце о Board классе:  

  • В нем есть метод рисования игрового поля.
  • Он включает в себя средства получения размера игрового поля и контекста холста.

Мы уже определили константы для размера ячеек и цвета доски, используя частные свойства. В drawBackground методе мы просто рисуем фон, используя width и height из контекста, а также цвет доски из частных свойств.

Для получения размера мы возвращаем количество ячеек в x и y координатах и размер ячейки. Это достигается путем деления размеров доски на размер ячейки из константы.

Далее, для средства получения контекста мы должны просто вернуть контекст canvas, ничего более.

import {
    CELL_SIZE,
    BOARD_COLOR
} from './constants.mjs'

export class Board {
    #cellSize = CELL_SIZE
    #backgroundColor = BOARD_COLOR

    constructor(canvas) {
        this.canvas = canvas
        this.ctx = this.canvas.getContext("2d")
    }

    drawBackground() {
        const { width, height } = this.canvas

        this.ctx.fillStyle = this.#backgroundColor
        this.ctx.fillRect(0, 0, width, height)
    }

    get size() {
        const { width, height } = this.canvas

        return {
            cellNumberX: Math.ceil(width / this.#cellSize),
            cellNumberY: Math.ceil(height / this.#cellSize),
            cellSize: this.#cellSize,
        }
    }

    get context() {
        return this.ctx
    }
}

Класс Cell

Класс Cell может только рисовать ячейку, но также определяет, является ли ячейка живой или мертвой. Фактически, этот класс инкапсулирует все правила игры всего в один маленький метод!

Вкратце о Cell классе:

  • В нем есть метод, позволяющий решить, является ли ячейка живой или мертвой.
  • В нем есть метод рисования ячейки в зависимости от ее состояния.
  • Он включает в себя частный геттер position и общедоступный геттер alive.
  • Он включает в себя сеттеры для установки количества alive и neighbor.

Ключевая деталь заключается в том, что x и y координаты хранятся в классе Game. Класс Cell получает контекст и размер ячейки от Board средства получения экземпляра класса.

На каждой итерации игры мы запускаем метод nextGeneration для определения того, жива ячейка или мертва, и drawCells метод для рисования ячейки.

Геттер position просто хранит массив для деструктурирования всех этих значений для контекстного метода fillRect, используемого при рисовании ячеек.

Сеттер и геттер для alive просто устанавливают и получают частную собственность, точно так же, как сеттер neighbors.

export class Cell {
    #alive = true
    #neighbors = 0

    constructor(ctx, x, y, cellSize) {
        this.ctx = ctx

        this.x = x
        this.y = y
        this.cellSize = cellSize
    }

    nextGeneration() {
        if (!this.#alive && this.#neighbors === 3) {
            this.#alive = true
        } else {
            this.#alive = this.#alive && (this.#neighbors === 2 || this.#neighbors === 3)
        }
    }

    draw() {
        if (this.#alive) {
            this.ctx.fillStyle = CELL_COLOR
            this.ctx.fillRect(...this.#position)
        }
    }

    get #position() {
        return [
            this.x * this.cellSize,
            this.y * this.cellSize,
            this.cellSize,
            this.cellSize
        ]
    }

    set alive(alive) {
        this.#alive = alive
    }

    get alive() {
        return this.#alive
    }

    set neighbors(neighbors) {
        this.#neighbors = neighbors
    }
}

Клаас Game

Итак, теперь давайте сведем все это воедино. Первый шаг, который я предлагаю, это объединить все наши классы и их методы, не вводя в текст новую логику.

Что у нас есть на руках?

Мы уже знаем, что знания о ячейках хранятся в Game классе; давайте используем для этого частное свойство. Фактически, это будет единственное частное свойство в этом классе.

Сначала все инициализируем. Мы должны получить экземпляр доски, чтобы нарисовать его, и использовать методы получения доски size и context. Для этого мы будем использовать конструктор класса, также устанавливая размер холста.

export class Game {
    #cells = []

    constructor(canvas) {
        this.canvas = canvas
        this.board = new Board(this.canvas)

        this.canvas.width = BOARD_WIDTH
        this.canvas.height = BOARD_HEIGHT
    }
}

Чтобы быстро запомнить, у Game класса должен быть initialize метод. Просто сделайте это, и давайте двигаться дальше.  

export class Game {
    // ...

    initialize = () => {}
}

На данный момент мы успешно внедрили множество методов рисования наших ячеек и управления ими. Однако не хватает важной детали – у нас пока нет ячеек! Давайте исправим этот пробел!

Метод initializeCells будет перебирать каждую ячейку, помещать ее в нашу #cells, случайным образом устанавливать alive статус ячейки, а затем draw это!

В доске у нас уже есть геттер size для получения x и y размеров этой доски. Мы будем использовать его для итерации.

export class Game {
    // ...

    initializeCells = () => {
        for (let i = 0; i < this.board.size.cellNumberX; i++) {
            this.#cells[i] = []

            for (let j = 0; j < this.board.size.cellNumberY; j++) {
                this.#cells[i][j] = new Cell(this.board.context, i, j, this.board.size.cellSize)
                this.#cells[i][j].alive = Math.random() > 0.8
                this.#cells[i][j].draw()
            }
        }
    }
}

Теперь, когда у нас есть ячейки и мы можем работать с ними, в методе updateCells мы дважды повторим каждую ячейку. Первая итерация заключается в вычислении и установке всех соседей ячейки, а вторая итерация - в рисовании нового состояния.

В каждой ячейке уже есть сеттер neighbors для первой итерации и методы, которые нам нужны для второй итерации: nextGeneration и draw тоже.

Вычисление количества соседей у ячейки может показаться простым, но это важный элемент логики, поэтому я перенес его в другой метод под названием updateCellNeighbors.

export class Game {
    // ...

    updateCells = () => {
        for (let i = 0; i < this.board.size.cellNumberX; i++) {
            for (let j = 0; j < this.board.size.cellNumberY; j++) {
                this.updateCellNeighbors(i, j);
            }
        }

        for (let i = 0; i < this.board.size.cellNumberX; i++) {
            for (let j = 0; j < this.board.size.cellNumberY; j++) {
                this.#cells[i][j].nextGeneration()
                this.#cells[i][j].draw()
            }
        }
    }

    updateCellNeighbors = (x, y) => {
}

До сих пор мы определяли методы, сеттеры и геттеры, но еще ничего не было нарисовано. У нас не так много осталось для завершения нашей игры. Теперь я хочу описать последнюю часть нашей головоломки, чтобы быть готовым к написанию окончательного метода updateCellNeighbors.

Методы this.board.drawBackground и updateCells вызываются в другом методе, который называется launch и вызывается снова с помощью requestAnimationFrame.

export class Game {
    // ...

    initialize = () => {
        this.initializeCells()
        this.launch()
    }

    // ...

    launch = () => {
        this.board.drawBackground()

        this.updateCells()

        requestAnimationFrame(this.launch)
    }

    // ...
}

updateCellNeighbors

Давайте воспользуемся моментом, чтобы подвести итог и наметить, что еще нужно написать. Последнее, что нам нужно написать, это updateCellNeighbors.

Сначала мы получаем карту всех соседей ячейки x и y.

export class Game {
    // ...

    updateCellNeighbors = (x, y) => {
        const neighborCoords = [
            [x, y + 1],
            [x, y - 1],
            [x + 1, y],
            [x - 1, y],
            [x + 1, y + 1],
            [x - 1, y - 1],
            [x + 1, y - 1],
            [x - 1, y + 1]
        ]
    }
}

Некоторые координаты могут выходить за пределы игрового поля. Мы можем легко проверить это, проверив, является ли x координата меньше 0 или x координата больше, чем this.board.size.cellNumberX. Для y то же самое.  

export class Game {
    // ...

    updateCellNeighbors = (x, y) => {
        // ...

        for (const coords of neighborCoords) {
            let [xCord, yCord] = coords;

            const xOutOfBounds = xCord < 0 || xCord >= this.board.size.cellNumberX
            const yOutOfBounds = yCord < 0 || yCord >= this.board.size.cellNumberY
        }
    }
}

Последнее, что нам нужно сделать для завершения, это выбрать соседнюю ячейку, проверить, является ли ячейка живой или мертвой, и посчитать ее. После этого задайте соседей, используя параметр сеттер neighbors.  

export class Game {
    // ...

    updateCellNeighbors = (x, y) => {
        let aliveNeighborsCount = 0

        // ...

        for (const coords of neighborCoords) {
            // ...

            const wrappedX = xOutOfBounds ? (xCord + this.board.size.cellNumberX) % this.board.size.cellNumberX : xCord
            const wrappedY = yOutOfBounds ? (yCord + this.board.size.cellNumberY) % this.board.size.cellNumberY : yCord

            if (this.#cells[wrappedX]?.[wrappedY]?.alive) {
                aliveNeighborsCount++
            }
        }

        this.#cells[x][y].neighbors = aliveNeighborsCount
    }
}

Поздравляем! Это конец нашего путешествия. Вы можете запускать игру, вносить изменения, доработки - просто не стесняйтесь.

Источник:

#JavaScript
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу