BALLSORT на $mol
Сегодня мы перепишем на $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
В директории
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-моделях других реализация:
По всем вопросам можно идти сюда.