BALLSORT на $mol

от автора

Сегодня мы перепишем на $mol эту демку почти пиксель в пиксель и напишем несколько тестов.

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

Изначально она была реализована на эффекторе + react, недавно несколько человек реализовали ее

Там где не указана ссылка на исходники отдельно, она есть в самой демке

Постановка задачи

Экраны

  • Start — стартовый экран на котором отображается заголовок, кнопка для запуска игры, и подвал с cсылками

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

  • Finish — когда шарики отсортированы, поверх второго экрана отображается третий экран. На нем находится заголовок «You won!», количество сделанных шагов, и кнопка «New game» которая открывает стартовый экран.

Механика игры

  1. Рисуются 6 трубок, четыре и них заполнены шарами и две пустые

  2. В заполненных трубках находятся по 4 шара, четырех разных цветов

  3. При клике на непустую трубку, она переходит в активное состояние

    • В активном состоянии верхний шар в трубке переносится на ее крышку

  4. Повторный клик по активной трубке дезактивирует ее, шар переносится обратно в нее

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

  6. Когда в одной и трубок все 4 шара одного цвета она переходит в статус готово, после этого шары в нее/из нее перемещать нельзя.

  7. Игра закончится, когда 4 трубки перейдут в статус готово.

Подготовка

Начнем с самого начала, а именно с разворачивания мола и создания репозитория под проект.

Установка MAM-окружения

Можно использовать gitpod.io, окружение установится автоматически, согласитесь установить плагины. Или можно установить все локально:

  1. Обновите NodeJS до LTS версии

  2. Загрузите репозиторий MAM

git clone https://github.com/hyoo-ru/mam.git ./mam && cd mam 
  1. Установите зависимости, вашим пакетным менеджером

npm install 
  1. Установите плагины для VSCode EditorConfig vscode-language-tree

MAM-окружение достаточно установить один раз и использовать для всех проектов!

Создание и настройка репозитория

  1. Идем сюда и нажимаем «Use this template» => «Create a new repository»

  2. Выбираем владельца, указываем имя репозитория «ballsort», опционально заполняем описание, тип репозитория ставим публичным и нажимаем «Create repository from template»

  3. Откройте настройки созданного репозитория нажав на «Settings»

  4. В левом меню нажмите на «Actions» => «General», в разделе «Workflow permissions» отметьте чекбокс «Read and Write permissions» и нажмите «Save». Это нужно чтобы экшен деплоя на «github pages» мог задеплоить приложение.

В качестве неймспейса будем использовать имя «hype» и опустим создание репозитория под неймспейс.

  1. Копируем ссылку на репозиторий и клонируем его в директорию mam/hype/ballsort

cd mam # Только подставьте вашу ссылку git clone https://github.com/PavelZubkov/ballsort.git hype/ballsort 

Минималное приложение

  1. Запускаем дев-сервер следующей командой

yarn start 
  1. Открываем в браузере http://127.0.0.1:9080

  2. Вы увидите список файлов и директорий, расположенных в директории mam. Нажмите на «hyoo», затем на «ballsort», затем на «app» — откроется белый экран, это ок т.к. в app присутствует только файл index.html.

  3. Откройте файл hype/ballsort/app/index.html и укажите имя модуля который будет монтироваться в атрибуте mol_view_root

<div mol_view_root="$hype_ballsort_app"></div> 
  1. В директории app создайте файл app.view.tree с содержимым ниже и сохраните его.

$hype_ballsort_app $mol_view sub / \Hello 
  1. Вернитесь в браузер и если все верно вы увидите приветствие

Деплой на github

В readme.md есть чек лист для настройки шаблонного репозитория

  1. Переименуйте файл hype/ballsort/hyoo_template_app.yml в hype/ballsort/hype_ballsort_app.yml и откройте его

  2. Измените имя на 3 строке

name: $hype_ballsort_app 
  1. На 19 строке укажите какой модуль будет собираться

- uses: hyoo-ru/mam_build@master2 with: package: 'hype/ballsort' modules: 'app' 
  1. Удалите блок деплоя в NPM, он начинается на 26 строке и заканчивается на 30 строке

  2. В блоке деплоя на Github Pages измените путь до директории с бандлами

- uses: hyoo-ru/gh-deploy@v4.4.1 if: github.ref == 'refs/heads/master' with: folder: 'hype/ballsort/app/-' 
  1. Сделайте коммит и отправьте изменения в репозиторий на github

  2. Возвращаемся в гитхаб, в разделе «Actions» ждем когда завершиться action «$hyoo_ballsort_app», и после него запуститься экшен «pages build and deployment»

  3. Если второй экшен упадет, то открываем «Settings» => «Pages», в разделе «Branch» указываем ветку для деплоя «gh-pages» и нажимаем «Save». После этого второй экшен запуститься повторно, а после его завершения в разделе настроек «Pages» будет находится ссылка на приложение.

  4. Если будут проблемы можете написать тут

Модель

Сначала напишем модель игры независимо от ее view-представления, а уже после отрисуем ее.

Я разделил игру на три модуля:

  • game — основная логика

  • ball — шар, тут только хранение цвета шаром

  • tube — логика трубы

Начнем с ball

  1. Создайте директорию ball и ts-файл в ней mam/hype/ballsort/ball/ball.ts

  2. Для VSCode в MAM-окружении доступно несколько сниппетов

    • class — шаблон для файла с классом

    • logic — шаблон для создания класса с логикой для view-компонента

    • styles — шаблон для css.ts-файла со стилями

    • tests — шаблон для файла с тестами

  3. Введите слово class, выберите «MAM class definition» и нажмите TAB или ENTER

  4. Введите имя класса $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

Переходим к основной логике игры.

  1. Создайте директорию game и ts-файл в ней mam/hype/ballsort/game/game.ts

  2. Создайте класс, назовите его $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 при первом запуске создаст массив с шарами и вернет его, а декоратор закеширует этот массив. При последующих вызовах будет возвращать массив из кеша. Работает так:

  1. Создаем массив через Array.from с указанным количеством элементов ball_count()

  2. Для каждого индекса в массиве создаем шар через Ball и устанавливаем этому шару цвет

  3. Возвращаем массив из свойства

Создаем трубки

Трубки создаются похожим образом

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

    1. Получает шары и перемешивает их через $mol_array_shuffle

    2. Кладет в переменную size, для более короткой записи при использовании

    3. Через Array.from создает массив, длина которого сразу учитывает и пустые трубки

    4. Для каждого элемента мы создаем трубку

    5. Устанавливаем шары для не пустой трубке или пустой массив если трубка должна быть пустой

    6. И возвращаем полученный массив трубок

Дело за малым

Нам потребуется свойства 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 ) }  } } 
  1. На вход принимаем объект трубки, в которую будем переносить шар из активной трубки

  2. Если активной трубки нет или активная трубка и трубка, в которую переносим это одна трубка — снимаем с трубки активность и выходим

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

  4. Если все ок, то методами take и put достаем шар из одной и кладем в другую

  5. Увеличиваем счетчик шагов moves

  6. Дезактивируем трубку

Предпоследний штрих

Чтобы не сваливать на 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, сохраните и загляните в консоль. Тест упал:

консоль с репортом о фейле теста

консоль с репортом о фейле теста

Можете удалить демо-тест.

Переход трубок в состояние готово

Для начала проверим что трубка корректно переходит в состояние готово.
Нам нужно:

  1. Создать игру

  2. Достать заполненную трубку

  3. На всякий случай убедимся, что изначально нет трубок в состоянии готово

  4. Установим всем шарам одинаковый цвет

  5. Убедимся, что трубка перешла в состояние готово

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()) },  } ) } 

Проверим активацию трубок

Для этого нам надо:

  1. Создать инстанс игры

  2. Взять из него трубку с шариками и пустую трубку

  3. На всякий случай убедимся, что активных трубок нет

  4. Кликнем по заполненной трубке, проверим что она активна

  5. Кликнем по пустой трубке и проверим что активных трубок снова нет

  6. Кликнем на трубку, в которую положили шар и убедимся, что она активировалась

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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *