Skip to content

Latest commit

 

History

History
executable file
·
467 lines (313 loc) · 47.5 KB

minesweeper-mvc.md

File metadata and controls

executable file
·
467 lines (313 loc) · 47.5 KB

Изучаем MVC на примере игры «Сапер»

У нас в курсе по JS есть такая задача, написать игру «Сапер»:

Wiki: http://ru.wikipedia.org/wiki/%D0%A1%D0%B0%D0%BF%D1%91%D1%80_(%D0%B8%D0%B3%D1%80%D0%B0)

Идея игры такая: на игровом поле где-то спрятаны мины. Игрок кликает по клеткам, открывая их. Если в клетке была мина, игрок проиграл. Если нет, то в клетке выводится цифра, показывающая общее число мин в соседних 8 клетках. Если игрок открыл все клетки, кроме заминированных, он победил. Если игрок открывает клетку, рядом с которой нет мин, то все соседние клетки открываются автоматически (если на них тоже нет мин, то процесс продолжается).

Правой кнопкой мыши на неоткрытых клетках можно расставлять флажки.

Обычно в качестве решения делают или набор функций, или класс, в котором логика игры и логика работы с DOM тесно переплетены. Ну например, проверку, открыта ли данная клетка делают через проверку наличия css-класса opened в соответствующей ячейке таблицы. В маленьком приложении вроде игры это еще допустимо, но если ты попробуешь писать более сложные приложения в таком стиле, то код будет очень запутанным, в нем трудно будет разбираться и тяжело поддерживать. Гораздо правильнее научиться разделять логику игры и отображения, для этого придумана архитектура MVC.

В MVC мы разделяем приложение на 3 слабо связанных части: Model (модель), View (вид) и Controller (контроллер). Модель отвечает только за игровую логику. Она хранит в себе состояние игрового поля (расположение мин, открытые клетки, поставленные флажки), методы для взаимодействия с ним (открыть клеточку, поставить флажок, узнать статус игры: победа, поражение, идет игра) и ничего не знает про отображение этого поля. Она не обращается к DOM (к document, window, к любым DOM-элементам), не обрабатывает события клика мышью, так что получается такая «вещь в себе», которая работает только со значениями в памяти и не взаимодействует с внешним миром. View занимается отображением данных из модели: он строит и обновляет DOM элементы отвечающие за вывод игрового поля на экране. Controller отвечает за обработку команд пользователя: он принимает события от пользователей, например клика по клеточке, вызывает методы модели для изменения ее состояния и view для отображения изменений на поле.

Если ты будешь гуглить, учти что есть 2 разновидности MVC: «серверная» и «клиентская». Серверная отличается тем, что там контроллер обрабатывает один запрос, отдает пользователю HTML-страницу и завершается, в то время как у нас приложение долгоживущее и обрабатывает много запросов, потому нам нужны будут немного другие подходы.

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

Задача «Сапер MVC»

Сделай игру «Сапер» с применением подхода MVC. Приложение должно поддерживать такие возможности:

  • должна быть возможность добавить на страницу несколько игровых полей, и они должны отображать одни и те же данные. То есть например при клике по клетке на первом поле она должна раскрыться на всех полях. В MVC это реализуется добавлением нескольких объектов View, работающих с одной общей моделью. Чтобы новые поля реагировали на щелчки по ним, может понадобится сделать еще несколько контроллеров

  • должна быть возможность управлять игрой через консоль отладчика (Ctrl + Shift + I, вкладка Console). В этой консоли можно писать команды на яваскрипте, которые будут выполняться на странице. Для удобства, пусть будет глобальная переменная game, являющаяся консольным контроллером, с такими методами:

    • game.show() рисует в консоли текущее состояние поля и игры (например методом console.log())
    • game.open('a2') - открывает клеточку и выводит новое состояние поля. При ошибке (клеточка уже открыта, игра завершена) выбрасывает исключение
    • game.setFlag('a3') - ставит флажок
    • game.removeFlag('a3') - снимает флажок
    • game.resign()- игрок признает поражение и сдается. Можно вызывать, только если игра еще не завершена
    • game.reset() - перезапускает игру (поле очищается, генерируются мины и т.д.) и выводит состояние поля.

    При этом все изменения, сделанные в консоли, должны отображаться и на экране. Также, должна быть возможность играть только в консоли (если не создавать экранного view).

  • (hard mode) сделай кнопку "отмены" сделанного хода и возврата к предыдущей ситуации. Разумеется, все игровые поля на экране должны соответствующим образом обновиться.

Комментарии

Модель

Давай обсудим, как лучше решить эту задачу. Первое, с чего мы должны начать - это Модель. Вообще, модель в MVC может обозначать не один класс, а целую часть приложения, но тут она будет состоять ровно из одного класса, который можно назвать MinesweeperGame или MinesweeperModel. Внутри она должна хранить состояние игры (завершена, продолжается) и игрового поля (координаты мин, открытые клеточки). Тут есть 2 варианта, как это хранить:

  • в виде 2-мерного массива, например this.openCells[y][x] = true помечает клеточку открытой

  • в виде 1-мерного списка, в таком случае открытие клеточки будет заключаться в добавлении в массив нового элемента:

    var cell = { x: 12, y: 3 }; // или new Cell(12, 3) или просто 'j3'
    this.openCells.push(cell);

Подход с 2-мерным массивом выглядит логичнее, но кто знает, может в некоторых случаях 1-мерный список будет удобнее. Например, его удобнее обходить циклом и с ним легко получить ответ на вопросы «сколько клеточек открыто», «какие 3 последних клеточки были открыты».

У модели должны быть методы для запроса игрового состояния (продолжается ли игра? завершена ли? открыта ли клеточка? сколько вокруг нее мин?) и его изменения (поставить флажок, открыть клеточку, сбросить игру, признать поражение). Также, можно сделать отладочные методы, например, сообщающие расположение мин.

Для обозначения состояний (например, статус игры) стоит использовать константы:

MinesweeperGame.STATUS_WIN = 'win';
MinesweeperGame.STATUS_LOSE = 'lose';
MinesweeperGame.STATUS_PLAYING = 'playing';

Представление

Задача представления - отображение игровой ситуации на экране. В случае браузера, это делается через взаимодействие с DOM. Представление должно иметь внутри ссылку на Модель, чтобы получать из нее нужные данные. Можно создать несколько Представлений для одной модели, тогда будет отображаться несколько одинаковых игровых полей. И разумеется в представлении может быть метод для отображения/обновления ситуации на игровом поле. Про варианты его реализации мы поговорим ниже.

Также, в представлении могут быть методы установки обработчиков событий (например "задать обработчик клика по клетке"). Это позволит контроллеру не работать с DOM напрямую, а устанавливать обработчики событий через View.

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

Представление отвечает не только за отображение игрового поля, но и за отображение сообщения о победе/поражении. Можно сделать все это в одном классе, можно для диалогов сделать отдельные представления - как тебе удобнее. В больших приложениях обычно для каждого окна или диалога есть свое представление (и свой контроллер), но тут можно обойтись и одним классом.

Что касается работы в консоли, то там за вывод картинки поля может отвечать контроллер. А можно сделать и отдельное представление для консоли - это позволит например писать в консоль уведомления об изменениях в игре ("открыта клеточка a2, мин: 3", "игрок проиграл", "игра перезапущена"). Думаю, так будет даже интереснее.

Контроллер

Контроллер отвечает за обработку команд пользователя. В этой задаче придется сделать 2 разных контроллера - контроллер, который обрабатывает события клика мышью по игровому полю и контроллер, который принимает команды из консоли. Это будут 2 разных класса.

При желании можно сделать и присоединить еще контроллеры. Например, можно сделать "клавиатурный" контроллер, который будет принимать команды от нажатия клавиш.

Контроллер содержит в себе ссылки на модель и на представление, так как ему надо взаимодействовать с ними. Например, при клике по клеточке контроллер сначала просит модель открыть эту клеточку, затем просит представление отобразить ситуацию на экране. Если игрок попал на мину и игра проиграна, представление должно отобразить соответствующее сообщение с кнопкой перезапуска игры (нажатие на которую тоже обработает контроллер).

Чтобы узнавать о кликах по полю, контроллеру надо как-то подписаться на события. Это достаточно сделать один раз, перед игрой. Он может ставить обработчики событий напрямую, но наверно лучше делать это через представление, чтобы за работу с DOM, поиск нужного элемента отвечало оно.

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

Обновление игрового поля

Можно подумать, что все главные моменты мы разобрали выше. На самом деле, мы пока не изучили самый сложный момент, а именно, не получили ответ на вопросы:

  • как представление узнает об изменениях модели (например об открытии клеточек или проигрыше в игре)
  • как представление обновляет поле (перерисовывает заново? только изменившиеся клеточки?)? Во втором случае, как оно узнает какие именно клеточки изменились?

Это настолько важный вопрос, что некоторые библиотеки предназначены исключительно для реализации view (например, knockout, react).

Для первого вопроса есть такие решения:

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

Для второго вопроса, поиск изменений, есть такие варианты:

  • полное обновление игрового поля каждый раз - искать изменения не требуется
  • отслеживание изменений за счет "отслеживаемых" свойств, которые умеют сообщать о своем изменении (подход knockout). Модель "умная" и сама сообщает, что именно в ней изменилось.
  • хранение копии состояния модели и сравнение нового состояния со старой копией для поиска изменений (подход angular)
  • хранение копии старого представления (виртуального дерева DOM с игровым полем), генерация нового виртуального дерева, их сравнение и обновление реального DOM на странице (подход react)

Разберем их подробнее.

Явное сообщение об изменениях в модели

В этом сценарии контроллер, сделав какие-то действия с моделью (например, попросив ее открыть клеточку) явно вызывает у представления метод обновления. Этот подход простой, но имеет недостатки:

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

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

Подписка на изменения в модели

В этом варианте используется паттерн Observer (наблюдатель). Ты наверняка знаком с событиями в браузере, когда ты можешь например подписаться на событие нажатия кнопки через addEventListener() и твоя функция будет вызываться (и получать подробности события) когда оно произойдет. Тут то же самое, только события соответствуют не действиям пользователя, а генерируются программно. Выглядеть это может так:

DomView.prototype.attach = function (model) {
    this.model = model;
    var that = this; // чтобы this был доступен в функции
    this.model.subscribe(MinesweeperModel.EVENT_UPDATE, function (e) {
        this.redraw(); // перерисовать картинку
    });
};
...

ES6 позволяет писать чуть короче, так как в "стрелочном" синтаксисе функций this берется из окружающей функции:

class DomView {
    attach(model) {
        this.model = model;
        this.model.subscribe(MinesweeperModel.EVENT_UPDATE, 
            e => { this.redraw(); }
        );
    }

    ...
}

Если он тебе не понятен, то можно делать на ES3. Пока ES6 поддерживается не везде, так что использовать его наверно в реальных приложениях не стоит, но почитать про него полезно.

Итак, представление подписывается на событие изменения модели. Когда она изменяется, вызывается функция и представление перерисовывает картинку.

Генерировать событие можно тоже по-разному:

  • модель может сама при каждом изменении явно вызывать обработчики
  • можно сделать "умные" поля в модели, которые умеют сообщать об изменениях. Например "умный" массив который ведет себя как обычный, но сообщает обо всех изменениях (подход knockout).
  • внешний класс может хранить копию старого состояния модели, сравнивать его с новым и в случае изменений генерировать события изменения
  • внешний класс может следить за моделью через появившийся в будущем в ES7 Object.observe(): https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Object/observe . при этом важно не забыть что если свойство модели хранит массив или объект (а не строку или число) то для отслеживания изменений в нем на него нужен отдельный наблюдатель.

Код, отвечающий за подписку на события и вызов обработчиков, может быть встроен в модель, а может быть сделан отдельным классом, так, что его можно использовать и в других случаях. Обычно его называют EventDispatcher, пример:

Для хранения информации о событии стоит сделать соответствующий класс, например GameEvent. Для обозначения типов изменений (изменение клеточки, установка флажка, изменение статуса игры) стоит сделать константы в нем. Объект этого класса передается подписчикам при возникновении события.

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

Преимущества этого подхода:

  • view всегда отображает актуальное состояние модели
  • контроллер может не иметь ссылки на view вообще, так как оно и само может узнавать об изменениях
  • к модели можно прикрепить любое число представлений, в том числе разнотипных. Например, можно сделать view которое будет сообщать об изменениях в игре в консоль в текстовом виде. И оно будет работать независимо от остальных view.

Определение, что именно изменилось в модели

Мы разобрались, как узнать о том, что модель изменилась. Теперь обсудим, как можно узнавать, что именно изменилось. Для начала посмотрим, какие типы изменений возможны:

  • изменение состояния клеточки "открыта"/"закрыта". Простановку флажка можно отнести тоже сюда, а можно сделать отдельным типом события
  • изменение статуса игры (идет игра/победа/поражение)

Групповые изменения (открытие нескольких клеточек) можно делать как одним событием со списком клеточек, так и несколькими - как удобнее.

Способы поиска изменений можно разделить на внутренние (модель сама сообщает об изменениях в ней) и внешние (внешний наблюдатель сравнивает новое и старое состояния модели). Разберем возможные варианты.

Не искать изменения вообще

Самый простой подход - не искать конкретные изменения, а заново перерисовывать представление. В случае браузера, это значит удаление игрового поля из DOM и создание его заново. Такой подход прост в реализации, но имеет недостатки:

  • низкая производительность. Поле 40×40 состоит из 1600 ячеек, и пересоздание такого количества элементов каждый ход сильно будет нагружать браузер. Особенно тяжело будет мобильным устройствам - ARM-процессоры слабее десктопных примерно раз в десять, и от большой нагрузки быстрее садится батарея. В более сложных приложениях все будет еще хуже.
  • при пересоздании таблицы надо заново ставить на ней обработчики событий

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

Нам он явно не подойдет.

Модель явно сообщает об изменениях в ней

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

MinesweeperModel.prototype.openCell = function (x, y) {
    // проверка что клеточку можно открыть
    ....
    this.openCells[y][x] = true;
    // создаем событие изменения состояния клетки
    var event = new CellChangeEvent(x, y);

    // послаем его подписчикам
    this.dispatchEvent(event);

    // проверяем, надо ли открыть соседние клетки
    this.openNeighbourCells(x, y);
};

Преимущества:

  • высокая (лучше всех других вариантов) производительность - мы не делаем каких-то сложных действий кроме создания небольшого объекта события. Представление обновляет только изменившиеся ячейки поля.

Недостатки:

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

Частично недостатки можно побороть, запретив обращаться к свойствам напрямую и меняя состояние только через несколько методов вроде updateCellStatus(x, y, status).

Модель отслеживает изменения за счет "умных" свойств

※ название "умные" придумал я и оно не официальное.

Обычная переменная или свойство объекта не умеют сообщать об изменениях своего значения (в ES7 хотят сделать возможность наблюдения за объектами, но это будет не скоро):

var a = 1;
a = 2; 

Но если мы вместо обычных свойств будем использовать объект или функцию, то сможем подписываться на события изменения их значения. Давай посмотрим, как это сделано в библиотеке knockout. Там "умные" свойства называются observable (наблюдаемое) и реализованы через функции. Ты вызываешь функцию без аргументов, чтобы прочитать значение свойства, и с аргументом, чтобы поменять:

// помещает в переименную a функцию-хранитель свойства и 
// задает начальное значение свойства равное 1
var a = ko.observable(1);

// подписывается на уведомления об изменениях
// в JS функция это тоже объект, потому такой синтаксис
// работает
a.subscribe(function (newValue) {
    console.log("Новое значение: " + newValue);
});

// получить значение
var value = a();

// записать новое значение
a(2); // произойдет вызов слушателя и будет выведено сообщение в консоль

Observable можно хранить и в свойстве объекта:

var game = {
    score: ko.observable(0)
};

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

var o = ko.observableArray([1, 2, 3]);
o.subscribe(function (v) { console.log(v); });
var array = o(); // получает обычный "глупый" массив
o.push(4); // выведет в консоль [1, 2, 3, 4]

Однако, добавить значение через индекс (o[1] = 3;) не получится. Чтобы отслеживать изменения полей объекта, надо каждое из них сделать observable.

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

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

  • мы вынуждены использовать специальные функции вместо хранения значений напрямую.
  • в случае сложной структуры данных с большой вложенностью, мы не можем подписаться на события рекурсивно, и должны делать subscribe() для каждого элемента, в том числе для вновь добавляемых.

Для борьбы с последним недостатком в ko есть плагин для конвертирования "обычного" JS-объекта в объект из observables:

var commonObject = {
    a: 1,
    b: [1, 2, 3],
    c: {
        d: 4
    }
};
var o = ko.mapping.fromJS(commonObject);

Посмотреть код и примеры можно тут:

Сравнение состояния модели

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

  • мы можем использовать обычную модель, с обычными свойствами, не закладывая в нее код отслеживания изменений
  • изменения не потеряются
  • отслеживание может работать рекурсивно

Минусы:

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

Пример:

var model = {
    a: 1,
    b: 2
};

var tracker = new ChangeTracker(model);
tracker.subscribe(....);

model.b = 3;

// ищет изменения и генерирует события
tracker.check(); 

Подобный подход используется в angular для определения изменений (код https://github.com/angular/angular.js/blob/master/src/ng/rootScope.js ).

В сети можно найти готовые библиотеки для глубокого сравнения:

Запускать процедуру сравнения надо явно. Можно делать это на стороне модели, вызывая метод после любых изменений.

Подход react

В react все устроено еще более сложно. Там используются "компоненты" (объекты, в нашем случае компонентом может быть игровое поле или отдельные его клетки), которым снаружи передаются какие-то данные и которые их отображают. В нашем случае, например, можно передавать модель игры в компонент, отвечающий за вывод игрового поля:

var model = ....;
ReactDOM.render(
    // Пытаемся отобразить поле, передав ему модель
    <GameField model={model}/>,

    // элемент внутри которого надо отобразить таблицу
    document.getElementById('container')
);

Сам компонент GameField содержит метод render, который берет данные из модели и выводит их. Для простоты я выведу их таблицей:

var GameField = React.createClass({
    render: function () {
        // получаем модель
        var model = this.props.model;
        var cellNumbers;
        // ... получаем из модели список открытых клеток и преобразуем
        // его в массив с цифрами вида [['x', 'x', 1], [2, 'x', 'x']], 
        // этот код я пропускаю
        ....

        // Строим массив строк таблицы
        var rows = [];
        for (var y = 0; y < cellNumbers.length; y++) {
            var rowNumbers = cellNumbers[y];
            var cells = [];
            for (var x = 0; x < rowNumbers.length; x++) {
                var number = rowNumbers[x];
                cells.push(<td>{number}</td>);
            }
            
            rows.push(<tr>{cells}</tr>);
        }

        // строим таблицу по имеющемуся массиву
        return (
            <table>
                {rows}
            </table>
        );
    }
});

Как видно, этот код на основе данных из модели строит таблицу. Однако он строит ее не из реальных элементов DOM, отображающихся в браузере, а из обычных JS объектов (которые потом преобразуются в настоящие элементы DOM). Когда мы хотим обновить картинку на экране, мы снова строим таблицу, а затем React сравнивает ее со старой копией, находит отличия (например у некоторых клеток поменялось содержимое) и меняет в реальном DOM только такие клетки.

Таким образом, это по сути подход с полной перерисовкой, оптимизированный за счет использования виртуального DOM. Однако, цикл в функции render() по всем ячейкам поля приходится делать каждый раз. Сами разработчики утверждают что это быстро, но я бы конечно относился к таким заявлениям с осторожностью и делал бы тесты, например на поле большого размера.

Преимущества подхода:

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

Недостатки:

  • неясно, что с производительностью
  • надо использовать странный синтаксис и смешивать в кучу JS и HTML код. Далее эти файлы надо обрабатывать препроцессором, так как напрямую JSX браузер не понимает
  • особенно неудобно, когда надо что-то выводить в цикле

Реализация возможности отмены

Напоследок обсудим, как сделать возможность откатывать сделанные ходы. Для этого нам надо вести лог изменений. Тут удобно использовать паттерн "Команда": https://ru.wikipedia.org/wiki/%D0%9A%D0%BE%D0%BC%D0%B0%D0%BD%D0%B4%D0%B0_(%D1%88%D0%B0%D0%B1%D0%BB%D0%BE%D0%BD_%D0%BF%D1%80%D0%BE%D0%B5%D0%BA%D1%82%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D1%8F)

Любые действия пользователя мы представляем в виде объектов разных классов-наследников Command, например:

  • OpenCommand (команда открыть клеточку)
  • FlagCommand (команда снять или поставить флажок)

В команде в свойствах хранятся параметры команды (например, координаты клеточки). Также в ней может храниться ссылка на модель. У команды есть 2 метода: execute() и cancel() (или rollback()). Первый сохраняет старое состояние модели (например состояние открываемой клеточки) и выполняет действия над ней, например просит ее открыть клеточку. Метод cancel() отменяет действие execute(), например просит модель вернуть клеточку в прежнее состояние.

В сапере открытие клетки может привести к открытию соседних клеток, потому надо это учитывать. Например, можно сохранять состояние вообще всех клеток перед выполнением команды (не очень эффективно), можно перед открытием клетки узнать у модели, какие еще клетки это затронет, и сохранить их список, можно перед открытием подписаться на события модели и собрать события открытия других клеток.

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

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

В любом случае, объект команды должен хранить в себе всю информацию, которая нужна для ее отмены. Обновление view делается в этом случае за счет событий, которые генерирует модель при открытии/закрытии клеточек.

Вот пример использования команды:

// команда открыть клеточку с координатами (12; 3)
var openCmd = new OpenCommand(model, 12, 3);
// выполняем ее
openCmd.execute();
// отменяем 
openCmd.cancel();

Чтобы сделать отмену более одного действия, нам нужно вести лог выполненных команд - можно просто складывать их в массив. Для этого можно завести объект, например с названием HistoryManager, и выполнять команды через него:

historyManager.runCommand(openCmd1);
historyManager.runCommand(openCmd2);

// откатить последнюю команду
historyManager.undo();

// повторить
historyManager.redo();

// узнать, есть ли возможность откатить команду
if (historyManager.canUndo()) { ... }

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

Лог выполненных команд может быть большим (например в приложении текстового редактора, если пользователь редактирует текст несколько часов подряд) и может потому занимать много памяти. Чтобы с этим бороться, можно ограничивать глубину отмены N последними действиями или (в случае браузера не получится, но работает в десктопных приложениях) выгружать старые команды на диск.

Некоторые команды, например, команда перезапуска игры, потребуют для отмены сохранять полностью старое состояние поля (включая координаты мин). Что с этим делать, зависит от задачи, в этой задаче можно очищать историю в таких случаях, то есть команда перезапуска игры является неотменяемой.

Посмотреть примеры кода

Ты можешь попробовать поискать примеры реализации игры "Сапер" на гитхабе: https://github.com/search?l=JavaScript&q=minesweeper+mvc&ref=searchresults&type=Repositories&utf8=%E2%9C%93

Хочешь попасть в Зал Славы и стать примером для новичков? Добавь в название или описание своего проекта на Гитхабе слова minesweeper и MVC.