Сегодня мы перепишем на $mol эту демку почти пиксель в пиксель и напишем несколько тестов.
Демка представляет собой игру, в которой перемещаются разноцветные шарики между трубками, цель игры — отсортировать шарики по цветам за наименьшее количество шагов.
Изначально она была реализована на эффекторе + react, недавно несколько человек реализовали ее
-
и две независимых версии на моле
Там где не указана ссылка на исходники отдельно, она есть в самой демке
Постановка задачи
Экраны
-
Start — стартовый экран на котором отображается заголовок, кнопка для запуска игры, и подвал с cсылками
-
Game — при клике на кнопку запуска, открывается экран с игрой, на котором необходимо сортировать шарики. В хедере находятся кнопки возврата на стартовый экран и рестарта игры, а также счетчик числа сделаyных шагов. В центре трубки с шарами. В подвале те же ссылки что и на первом экране.
-
Finish — когда шарики отсортированы, поверх второго экрана отображается третий экран. На нем находится заголовок «You won!», количество сделанных шагов, и кнопка «New game» которая открывает стартовый экран.
Механика игры
-
Рисуются 6 трубок, четыре и них заполнены шарами и две пустые
-
В заполненных трубках находятся по 4 шара, четырех разных цветов
-
При клике на непустую трубку, она переходит в активное состояние
-
В активном состоянии верхний шар в трубке переносится на ее крышку
-
-
Повторный клик по активной трубке дезактивирует ее, шар переносится обратно в нее
-
После активации трубки, клик по другой трубке переносит шар с крышки в другую трубку при условии, что другая трубка пуста или верхний шар другой трубки такого же цвета как шар на крышке активной трубке
-
Когда в одной и трубок все 4 шара одного цвета она переходит в статус готово, после этого шары в нее/из нее перемещать нельзя.
-
Игра закончится, когда 4 трубки перейдут в статус готово.
Подготовка
Начнем с самого начала, а именно с разворачивания мола и создания репозитория под проект.
Установка MAM-окружения
Можно использовать gitpod.io, окружение установится автоматически, согласитесь установить плагины. Или можно установить все локально:
-
Обновите NodeJS до LTS версии
-
Загрузите репозиторий MAM
git clone https://github.com/hyoo-ru/mam.git ./mam && cd mam
-
Установите зависимости, вашим пакетным менеджером
npm install
-
Установите плагины для VSCode EditorConfig vscode-language-tree
MAM-окружение достаточно установить один раз и использовать для всех проектов!
Создание и настройка репозитория
-
Идем сюда и нажимаем «Use this template» => «Create a new repository»
-
Выбираем владельца, указываем имя репозитория «ballsort», опционально заполняем описание, тип репозитория ставим публичным и нажимаем «Create repository from template»
-
Откройте настройки созданного репозитория нажав на «Settings»
-
В левом меню нажмите на «Actions» => «General», в разделе «Workflow permissions» отметьте чекбокс «Read and Write permissions» и нажмите «Save». Это нужно чтобы экшен деплоя на «github pages» мог задеплоить приложение.
В качестве неймспейса будем использовать имя «hype» и опустим создание репозитория под неймспейс.
-
Копируем ссылку на репозиторий и клонируем его в директорию
mam/hype/ballsort
cd mam # Только подставьте вашу ссылку git clone https://github.com/PavelZubkov/ballsort.git hype/ballsort
Минималное приложение
-
Запускаем дев-сервер следующей командой
yarn start
-
Открываем в браузере
http://127.0.0.1:9080
-
Вы увидите список файлов и директорий, расположенных в директории
mam
. Нажмите на «hyoo», затем на «ballsort», затем на «app» — откроется белый экран, это ок т.к. вapp
присутствует только файлindex.html
. -
Откройте файл
hype/ballsort/app/index.html
и укажите имя модуля который будет монтироваться в атрибутеmol_view_root
<div mol_view_root="$hype_ballsort_app"></div>
-
В директории
app
создайте файлapp.view.tree
с содержимым ниже и сохраните его.
$hype_ballsort_app $mol_view sub / \Hello
-
Вернитесь в браузер и если все верно вы увидите приветствие
Деплой на github
В readme.md есть чек лист для настройки шаблонного репозитория
-
Переименуйте файл
hype/ballsort/hyoo_template_app.yml
вhype/ballsort/hype_ballsort_app.yml
и откройте его -
Измените имя на 3 строке
name: $hype_ballsort_app
-
На 19 строке укажите какой модуль будет собираться
- uses: hyoo-ru/mam_build@master2 with: package: 'hype/ballsort' modules: 'app'
-
Удалите блок деплоя в NPM, он начинается на 26 строке и заканчивается на 30 строке
-
В блоке деплоя на Github Pages измените путь до директории с бандлами
- uses: hyoo-ru/gh-deploy@v4.4.1 if: github.ref == 'refs/heads/master' with: folder: 'hype/ballsort/app/-'
-
Сделайте коммит и отправьте изменения в репозиторий на github
-
Возвращаемся в гитхаб, в разделе «Actions» ждем когда завершиться action «$hyoo_ballsort_app», и после него запуститься экшен «pages build and deployment»
-
Если второй экшен упадет, то открываем «Settings» => «Pages», в разделе «Branch» указываем ветку для деплоя «gh-pages» и нажимаем «Save». После этого второй экшен запуститься повторно, а после его завершения в разделе настроек «Pages» будет находится ссылка на приложение.
-
Если будут проблемы можете написать тут
Модель
Сначала напишем модель игры независимо от ее view-представления, а уже после отрисуем ее.
Я разделил игру на три модуля:
-
game — основная логика
-
ball — шар, тут только хранение цвета шаром
-
tube — логика трубы
Начнем с ball
-
Создайте директорию ball и ts-файл в ней
mam/hype/ballsort/ball/ball.ts
-
Для VSCode в MAM-окружении доступно несколько сниппетов
-
class — шаблон для файла с классом
-
logic — шаблон для создания класса с логикой для view-компонента
-
styles — шаблон для css.ts-файла со стилями
-
tests — шаблон для файла с тестами
-
-
Введите слово
class
, выберите «MAM class definition» и нажмите TAB или ENTER -
Введите имя класса $hype_ballsort_ball и он должен наследоваться от $mol_object
$mol_object — это базовый класс с общей логикой, можете посмотреть его исходники самостоятельно. Т.к. имя сущности соответствует расположению сущности в исходном коде, то сможете без труда найти его. Репозиторий mol загрузился в MAM-окружение при установке сборщика. Можно просто нажать CTRL+P, ввести mol/object и нажать ENTER.
Сейчас у вас есть пустой класс:
namespace $ { export class $hoop_ballsort_ball extends $mol_object { } }
ball будет хранить одно состояние — цвет шара, создадим свойство для него
namespace $ { export class $hype_ballsort_ball extends $mol_object { @ $mol_mem color(next?: number) { return next ?? 0 } } }
В качестве значения цвета, мы будем использовать целые числа по порядку с 0 и далее. А при отображении view-компонент сам определит для какого числа какой цвет использовать.
Как это работает
При вызове метода без аргументов, он работает как геттер. При вызове с аргументом как сеттер.
Декоратор кеширует возвращенное значение из метода при первом вызове, а при повторном уже не запускает код метода, а просто возвращает значение из кеша.
Вновь код метода будет запущен только в двух случаях:
-
если передали в него новое значение
-
если код метода использует другие методы с декоратором, то в случае изменения их значения, декоратор поймет, что закешированное значение уже неактуально и при следующем вызове запустит код метода, чтобы получить актуальное значение
const obj = new $hype_ballsort_ball obj.color() // 0 obj.color(1) // 1 obj.color() // 1
tube
Создайте директорию tube и ts-файл в ней mam/hype/ballsort/tube/tube.ts
За что будет отвечать трубка
-
хранить массив шаров помещенных в нее
-
определять находится ли она в состоянии готово
-
выдавать нам верхний шар
-
принимать от нас шар и класть наверх
Создайте класс, назовите его $hype_ballsort_tube и отнаследуйте от $mol_object.
namespace $ { export class $hype_ballsort_tube extends $mol_object { } }
Добавим свойство для хранения шаров. Тут все точно также, как и у свойства color
у шара, только в качестве значения используется массив, в котором хранятся объекты — инстансы класса $hype_ballsort_ball. По умолчанию возвращается пустой массив.
namespace $ { export class $hype_ballsort_tube extends $mol_object { @ $mol_mem balls( next?: $hype_ballsort_ball[] ) { return next ?? [] } } }
Чтобы отформатировать код также как у меня, нажмите CTRL+SHIFT+P, введите «Format» и выберите команду «Format document» 🙂
Теперь добавим свойство для определения состояния готово. Ему нужно знать сколько шаров одного цвета должно быть в трубке для перехода в готово, для этого добавим свойство size
, без декоратора, оно будет переопределяется при инстанцировании класса.
namespace $ { export class $hype_ballsort_tube extends $mol_object { //... size() { return 0 } @ $mol_mem complete() { const [ ball, ...balls ] = this.balls() return this.balls().length === this.size() && balls.every( obj => obj.color() === ball.color() ) } } }
Тут мы просто отделяем первый шар от остальных, и проверяем что цвет первого шара равен цветам остальных шаров. А также проверяем что количество шаров равно нужно величине.
Декоратор тут тоже кеширует возвращаемое значение, но само свойство read-only, т.к. в нем не предусмотрена передача значения при вызове. Оно зависит от свойства balls
и свойств color
у шаров, когда они изменятся, оно сбросит кеш и вернет актуальное значение.
И нам осталось добавить только свойства для вытаскивания верхнего шара и для того чтобы положить шар наверх.
namespace $ { export class $hype_ballsort_tube extends $mol_object { //... @ $mol_action take() { const next = this.balls().slice() const ball = next.pop() this.balls( [ ...next ] ) return ball } @ $mol_action put( obj: $hype_ballsort_ball ) { this.balls( [ ...this.balls(), obj ] ) } } }
-
take
-
берет массив из свойства
balls
-
создает его копию. Нельзя мутировать массив, который хранится в декораторе!
-
из копии вытаскивает верхний шар
-
записывает обратно в
balls
массив без верхнего шара -
и возвращает шар
-
-
put
-
принимает шар в качестве аргумента
-
записывает в свойство
balls
новый массив, который создается из старого плюс принятый шар
-
game
Переходим к основной логике игры.
-
Создайте директорию game и ts-файл в ней
mam/hype/ballsort/game/game.ts
-
Создайте класс, назовите его $hype_ballsort_game и отнаследуйте от $mol_object
namespace $ { export class $hype_ballsort_game extends $mol_object { } }
Мы не будем хардкодить сказанное в правилах, что заполненных трубок только четыре, что всего четыре цвета у шаров и т.д. Для начала создадим свойства в которых будут храниться эти константы
namespace $ { export class $hype_ballsort_game extends $mol_object { color_count() { return 4 } // Количество цветов // Количество шаров одного цвета // которое надо собрать в трубке // для перехода в состоянии готово tube_size() { return 4 } // Количество пустых трубок tube_empty_count() { return 2 } // Общее количество трубок tube_count() { return this.color_count() + this.tube_empty_count() } // Общее количество шаров ball_count() { return this.tube_size() * this.color_count() } } }
Теперь нам нужно научиться инстанцировать шары и создать требуемое количество шаров.
namespace $ { export class $hype_ballsort_game extends $mol_object { //... @ $mol_mem_key Ball( index: number ) { return new $hype_ballsort_ball } } }
Как это работает 2?
Декоратор $mol_mem_key
работает точно также, как и декоратор $mol_mem
, за одним исключением — первым аргументом он всегда принимает ключ. Ключ является обязательным параметром. В итоге у нас получает набор из произвольного количества состояний, с доступом к каждому по ключу.
В данном случае свойство Ball
является read-only свойством, т.к. у него нет второго параметра next
. Оно возвращает инстанс класс, т.е. это свойство-фабрика. А в качестве ключей будут использоваться индексы и у шаров и у трубок, но вообще можно использовать произвольный объект.
При вызове с любым индексом, оно вернет объект и запомнит его под этим индексом, и при следующих обращениях будет возвращать уже созданный объект из кеша.
Важно: инстанцировать объекты необходимо через свойства-фабрики!
const obj = new $hype_ballsort_game const ball1 = obj.Ball(0) // возвращает объект - инстанс шара const ball2 = obj.Ball(1) ball1 === ball2 // false - это два разных инстанса
Теперь создадим свойство генерирующее шары
namespace $ { export class $hoop_ballsort_game extends $mol_object { //... @$mol_mem_key Ball( index: number ) { return new $hype_ballsort_ball } @$mol_mem balls() { return Array.from( { length: this.ball_count() } ).map( ( _, index ) => { const obj = this.Ball( index ) obj.color( index % this.tube_size() ) return obj } ) } } }
-
Свойство
balls
при первом запуске создаст массив с шарами и вернет его, а декоратор закеширует этот массив. При последующих вызовах будет возвращать массив из кеша. Работает так:
-
Создаем массив через
Array.from
с указанным количеством элементовball_count()
-
Для каждого индекса в массиве создаем шар через
Ball
и устанавливаем этому шару цвет -
Возвращаем массив из свойства
Создаем трубки
Трубки создаются похожим образом
namespace $ { export class $hoop_ballsort_game extends $mol_object { //... @ $mol_mem_key Tube( index: number ) { const obj = new $hype_ballsort_tube obj.size = () => this.tube_size() return obj } @ $mol_mem tubes() { const balls = $mol_array_shuffle( this.balls() ) const size = this.tube_size() return Array.from( { length: this.tube_count() } ).map( ( _, index ) => { const obj = this.Tube( index ) const list = index < this.color_count() ? balls.slice( index * size, index * size + size ) : [] obj.balls( list ) return obj } ) } } }
-
Свойство-фабрика
Tube
работает аналогичным образом, как иBall
, только оно после создания объекта устанавливает емуsize
, мы говорили про это выше — оно нужно трубке чтобы определить готовность. -
Свойство
tubes
-
Получает шары и перемешивает их через
$mol_array_shuffle
-
Кладет в переменную
size
, для более короткой записи при использовании -
Через
Array.from
создает массив, длина которого сразу учитывает и пустые трубки -
Для каждого элемента мы создаем трубку
-
Устанавливаем шары для не пустой трубке или пустой массив если трубка должна быть пустой
-
И возвращаем полученный массив трубок
-
Дело за малым
Нам потребуется свойства moves
в котором будем хранить число шагов и увеличивать с каждым ходом.
namespace $ { export class $hoop_ballsort_game extends $mol_object { //... @$mol_mem moves( next?: number ) { return next ?? 0 } } }
Нам понадобится свойство для хранения активной трубки. Напомню: при клике пользователя по трубке, она становится активной.
namespace $ { export class $hoop_ballsort_game extends $mol_object { //... @ $mol_mem tube_active( next?: $hype_ballsort_tube | null ) { if (next?.balls().length === 0) return null if (next?.complete()) return null return next ?? null } } }
Это изменяемое свойство — у него есть параметр next
, хранит оно объект активной трубки. А также оно принимает значение null
, оно туда будет передаваться, когда необходимо дезактивировать трубку.
А также
-
Если в трубке шаров нет — то ее нельзя активировать
-
Если трубка уже в состоянии готово — ее тоже нельзя активировать
Теперь напишем свойство, которое будет переносить шар из активной трубки from
, в нужную to
.
namespace $ { export class $hoop_ballsort_game extends $mol_object { //... @ $mol_action ball_move( to: $hype_ballsort_tube ) { const from = this.tube_active() if (to === from || !from) return this.tube_active(null) const from_color = from?.balls().at(-1)?.color() const to_color = to.balls().at(-1)?.color() if (to.balls().length && from_color !== to_color) return const ball = from.take()! to.put( ball ) this.moves( this.moves() + 1 ) this.tube_active( null ) } } }
-
На вход принимаем объект трубки, в которую будем переносить шар из активной трубки
-
Если активной трубки нет или активная трубка и трубка, в которую переносим это одна трубка — снимаем с трубки активность и выходим
-
Проверяем что цвета верхних шаров в обоих трубках совпадают, т.к. друг на друга можно класть шары только одого цвета
-
Если все ок, то методами
take
иput
достаем шар из одной и кладем в другую -
Увеличиваем счетчик шагов
moves
-
Дезактивируем трубку
Предпоследний штрих
Чтобы не сваливать на view-компонент задачу поочередного вызывания tube_active
и ball_move
, добавим свойство tube_click
.
namespace $ { export class $hoop_ballsort_game extends $mol_object { //... @ $mol_action tube_click( tube: $hype_ballsort_tube ) { const tube_active = this.tube_active() tube_active === null ? this.tube_active( tube ) : this.ball_move( tube ) } } }
View-компонент будет вызывать этой свойство, передавая туда трубку по которой кликнул пользователь.
Логика проста:
-
Если при клике активной трубки нет, то делаем активной переданную трубку
-
Если активная трубка уже есть, то вызываем
ball_move
, что бы шар переместился из активной в переданную трубку
Последний штрих
Нам нужно свойство, которое будет сигнализировать о том, что игра закончена.
namespace $ { export class $hoop_ballsort_game extends $mol_object { //... @ $mol_mem finished() { return this.tubes().every( tube => tube.complete() || tube.balls().length === 0 ) } } }
Игра заканчивается, когда каждая трубка в статусе готово или у нее нет шаров.
Мы тут специально не обрабатываем некоторые случаи, например в активации трубы нет проверки на то что игра закончена, т.к. предполагаем что интерфейс не будет обрабатывать клики на трубки после окончания игры.
Время тестов
Создайте файл game.test.ts
в директории hype/ballsort/game
, и выполните сниппет tests
.
namespace $.$$ { $mol_test({ ""( $ ) { }, }) }
Как это работает 3?
Для описания тестов есть функция $mol_test
, она принимает объект с тестами. Каждый тест — это метод на этом объекте. Имя метода — название теста, а код метода — это код теста. Так же в метод при запуске передается контекст, но это уже совсем другая история.
Сначала напишем простой демо-тест.
namespace $.$$ { $mol_test({ "Moves initially zero"() { const obj = new $hype_ballsort_game $mol_assert_equal(obj.moves(), 0) }, }) }
Чтобы запустить тесты, обычно ничего делать не надо, когда мы открываем в браузере какое-то приложение, dev-сервер собирает отдельный бандл с тестами, который содержит тесты всех модулей от которых зависит приложение.
Можно заметить, что в урле запрашивается файл test.html
, в него сборщик добавляет загрузку бандла с тестами. Тесты прогоняются при каждой перезагрузке страницы.
Но у нас пока в приложении выводится только приветствие, мы можем попросить dev-сервер отдать нам test.html
модуля game
, он положит туда тест, который мы написали.
Откройте ссылку http://127.0.0.1:9080/hoop/ballsort/game/-/test.html
— вы увидите белый экран, в game
нет view-компонентов. Откройте консоль в девтулзах.
Зеленьким «All test passed» — ни один тест не упал. Число 92 — количество запущеных тестов, это тесты модулей от которых зависит наш код.
Сломайте тест, вместо 0 поставив 1, сохраните и загляните в консоль. Тест упал:
Можете удалить демо-тест.
Переход трубок в состояние готово
Для начала проверим что трубка корректно переходит в состояние готово.
Нам нужно:
-
Создать игру
-
Достать заполненную трубку
-
На всякий случай убедимся, что изначально нет трубок в состоянии готово
-
Установим всем шарам одинаковый цвет
-
Убедимся, что трубка перешла в состояние готово
namespace $ { $mol_test( { 'tube completing'() { const game = new $hype_ballsort_game // 1 const tube = game.tubes().find( obj => obj.balls().length > 0 )! // 2 $mol_assert_not( tube.complete() ) // 3 tube.balls().forEach( ball => ball.color( 0 ) ) // 4 $mol_assert_ok( tube.complete() ) //5 } } ) }
Проверим что трубка в состоянии готово не активируется
namespace $ { $mol_test( { //... 'completed tube non activation'() { // Создаем игру и берем трубку с шарами const game = new $hype_ballsort_game const tube = game.tubes().find( obj => obj.balls().length > 0 )! $mol_assert_not(game.tube_active()) // Активных нет tube.balls().map(obj => obj.color(0)) // Красим шары game.tube_click(tube) // Кликаем по трубке $mol_assert_not(game.tube_active()) // Активных трубок все еще нет }, } ) }
Проверим что пустая трубка не активируется
namespace $ { $mol_test( { //... 'empty tube non activation'() { const game = new $hype_ballsort_game // Берем пустую трубку const tube = game.tubes().find( obj => obj.balls().length === 0 )! $mol_assert_not(game.tube_active()) // Кликаем и убеджаемся что активных трубок нет game.tube_click(tube) $mol_assert_not(game.tube_active()) }, } ) }
Проверим активацию трубок
Для этого нам надо:
-
Создать инстанс игры
-
Взять из него трубку с шариками и пустую трубку
-
На всякий случай убедимся, что активных трубок нет
-
Кликнем по заполненной трубке, проверим что она активна
-
Кликнем по пустой трубке и проверим что активных трубок снова нет
-
Кликнем на трубку, в которую положили шар и убедимся, что она активировалась
namespace $ { $mol_test( { //... 'tube activation'() { const game = new $hype_ballsort_game const tube_filled = game.tubes().find( obj => obj.balls().length > 0 )! const tube_empty = game.tubes().find( obj => obj.balls().length === 0 )! $mol_assert_not(game.tube_active()) game.tube_click(tube_filled) $mol_assert_equal(tube_filled, game.tube_active()) game.tube_click(tube_empty) $mol_assert_not(game.tube_active()) game.tube_click(tube_empty) $mol_assert_equal(tube_empty, game.tube_active()) }, } ) }
Попробуем переместить шар
namespace $ { $mol_test( { //... 'ball moving'() { const game = new $hype_ballsort_game // Берем заполненную и пустую трубки, а также шар который будет перемещаться const tube_filled = game.tubes().find( obj => obj.balls().length > 0 )! const tube_empty = game.tubes().find( obj => obj.balls().length === 0 )! const ball_moving = tube_filled.balls().at( -1 )! // Кликаем на заполненую трубку и на пусую game.tube_click( tube_filled ) game.tube_click( tube_empty ) // Убеждаемся что именно этот шар убыл из одной трубки и прибыл в другую $mol_assert_equal( tube_filled.balls().length, game.tube_size() - 1 ) $mol_assert_not( tube_filled.balls().includes( ball_moving ) ) $mol_assert_equal( tube_empty.balls().length, 1 ) $mol_assert_ok( tube_empty.balls().includes( ball_moving ) ) }, } ) }
Проверим что счетчик увеличивается при перемещении шара
namespace $ { $mol_test( { //... 'moves increment'() { const game = new $hype_ballsort_game const tube_filled = game.tubes().find( obj => obj.balls().length > 0 )! const tube_empty = game.tubes().find( obj => obj.balls().length === 0 )! game.tube_click( tube_filled ) game.tube_click( tube_empty ) $mol_assert_equal( game.moves(), 1 ) }, } ) }
Проверим что игра заканчивается
namespace $ { $mol_test( { //... 'game finish'() { const game = new $hype_ballsort_game $mol_assert_not( game.finished() ) game.balls().forEach( ball => ball.color( 0 ) ) $mol_assert_ok( game.finished() ) }, } ) }
Что дальше?
Продолжение с pixel perfect версткой будет в следующей части. А также напишем тестов для проверки всего приложения.
А пока можете разобраться в моделях/view-моделях других реализация:
-
Весь код из статьи в этом репозитории
По всем вопросам можно идти сюда.
ссылка на оригинал статьи https://habr.com/ru/articles/741040/
Добавить комментарий