BALLSORT на $mol. Часть 2

от автора

Сегодня мы продолжим переписывание на $mol этой демки. Кто не читал первую часть, рекомендую сначала ознакомиться с ней BALLSORT на $mol. Часть 1

Напомню задачу

gif

gif

Экраны

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

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

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

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

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

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

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

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

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

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

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

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

Отображение

Мы создадим отдельные модули для отображения:

  • Ссылки

  • Кнопки

  • Шара

  • Трубки

А затем соберем все в модуле app.

link

Создайте директорию ballsort/link и файл в ней link.view.tree.

$hype_ballsort_link $mol_view dom_name \a attr * href <= href \ target <= target \_self sub / <= title \ 

view.tree — это DSL, прежде чем продолжать рекомендую ознакомиться с этими трудами: Композиция компонентов, Декларативная композиция компонентов

После того как вы ознакомились с материалами по ссылкам выше вы понимаете, что мы описали класс $hype_ballsort_link, который наследуется от базового класса view-компонент $mol_view. Имя тега изменено на a, у dom-ноды установлены два аттрибута href и target на которые забиндены одноименные свойства, а в качестве ребенка dom-ноды выводим строку из свойства title.

Отрисуем этот компонент. Откройте в браузере ссылку http://127.0.0.1:9080/hype/ballsort/app/-/test.html — это модуль приложения, в котором находится файл index.html. На экране отображается только строка приветствия.

Отредактируйте файл app/app.view.tree

$hype_ballsort_app $mol_view sub / <= Components $mol_list rows / <= Link $hype_ballsort_link title \Ссылка href \example.com target \_blank 

$mol_list — это view-компонент из мола, для отображения вертикального списка, временно воспользуемся им.

Заглянем в браузер:

ссылка

ссылка

Добавим стилей, создайте в link файл link.view.css.ts

namespace $.$$ {  $mol_style_define( $hype_ballsort_link, {  color: 'lightgray', padding: ['0.25rem', '1rem'],  } )  } 

Про css.ts можно почитать тут: Каскадные стили компонент, Продвинутый CSS-in-TS, $mol_style readme.md

Готово, компонент ссылки теперь имеет необходимый функционал и выглядит также как в оригинальном приложении.

button

Проделываем все тоже самое для компонента кнопки.

Файл ballsort/button/button.view.tree:

$hype_ballsort_button $mol_view dom_name \button sub / <= title \ event * click? <=> click? null 

Выводим кнопку в app:

$hype_ballsort_app $mol_view sub / <= Components $mol_list rows / <= Link $hypr_ballsort_link title \Ссылка href \example.com target \_blank <= Button $hype_ballsort_button title \Кнопка 

Убеждаемся, что кнопка подтянулась:

Кнопка

Кнопка

Добавляем стили в файле button.view.css.ts:

namespace $.$$ {  $mol_style_define( $hype_ballsort_button, {  width: 'fit-content', backgroundColor: 'white', color: 'black', padding: ['0.6rem', '1rem'], fontSize: '1.3rem', margin: [0, '0.2rem'], border: { width: '2px', style: 'solid', color: 'lightgray', }, cursor: 'pointer', position: 'relative',  ':hover': { backgroundColor: '#f1f1f1', },  ':focus': { outline: 'none', boxShadow: '0 0 0 4px lightblue', borderColor: 'lightblue', },  } )  } 
Кнопка со стилями

Кнопка со стилями

ball

Теперь создадим компонент для шара. Имя $hype_ballsort_ball уже занято в классе модели, view-шку шара поместим в $hype_ballsort_ball_view.

Создайте файл ballsort/ball/view/view.view.tree

Комментарии во view.tree начинаются со знака минус

$hype_ballsort_ball_view $mol_view - Компонент шара будет принимать модель шара, из которой он достает цвет ball $hype_ballsort_ball - Для раскраски шара будет использоваться радиальный градиент из двух цветов style * --main-color <= color_main \ --light-color <= color_light \ - Цвета заранее заготовлены в массиве, такие же как в оригинальном приложении - Всего предусмотрено 12 цветов, индексы от 0 до 11 - цвет по индексу 0 - основной цвет - color_main - цвет по индексу 0 + 1 - второй цвет - color_light colors / \#8F7E22 \#FFE600 \#247516 \#70FF00 \#466799 \#00B2FF \#29777C \#00FFF0 \#17206F \#4A72FF \#BABABA \#FFFFFF \#4C3283 \#9D50FF \#8B11C5 \#FF00F5 \#9D0D41 \#FF60B5 \#4B0000 \#FF0000 \#79480F \#FF7A00 \#343434 \#B1B1B1 

Рисуем шар в app

$hype_ballsort_app $mol_view sub / <= Components $mol_list rows / <= Link $hype_ballsort_link title \Ссылка href \example.com target \_blank <= Button $hype_ballsort_button title \Кнопка <= Ball $hype_ballsort_ball_view 

И не видем его, но он есть

Невидимый шар

Невидимый шар

Добавим ему стилей, создайте файл ball/view/view.view.css.ts

namespace $.$$ {  $mol_style_define( $hype_ballsort_ball_view, {  width: '2rem', height: '2rem', boxSizing: 'content-box',  border: { radius: '50%', width: '2px', style: 'solid', color: 'black', },  margin: '1px', position: 'relative', backgroundImage: 'radial-gradient(circle at 65% 15%, white 1px, var(--light-color) 3%, var(--main-color) 60%, var(--light-color) 100%)',  } )  } 
Что-то появилось

Что-то появилось

Теперь нужно научить шар брать нужные цвета, добавим логики. Создайте файл view.view.ts.

namespace $.$$ { export class $hype_ballsort_ball_view extends $.$hype_ballsort_ball_view {  // В свойстве ball хранится инстанс модели шара // из модели достаем цвет `color()` и умножаем на 2 // чтобы получить правильный индекс в массиве цветов color_index() { return this.ball().color() * 2 }  // Достаем из массива основной цвет по посчитанному индексу // На случай если нам пришел индекс выходящий за массив с цветами // выводим красный цвет color_main() { return this.colors()[ this.color_index() ] ?? 'red' }  // Достаем второй цвет по индексу + 1 // и устанавливаем значение по умолчанию color_light() { return this.colors()[ this.color_index() + 1 ] ?? 'white' }  } }  

И т.к. в модели по дефолту стоит цвет 0, видим первый цвет из массива

желтый

желтый

tube

Нам осталось создать компонент для трубки. По аналогии с шаром, создайте файл tube/view/view.view.tree

Сделаем его на основе $mol_list, т.к. он состоит из двух вертикальных частей

  • крышка

  • сама трубка с шариками, которая тоже на $mol_list

$hype_ballsort_tube_view $mol_list tube $hype_ballsort_tube active false event * click? <=> click? null rows / <= Roof $mol_view sub / <= roof null <= Balls $mol_list style * min-height \10rem attr * data-complete <= complete false rows <= balls / <= Ball*0 $hype_ballsort_ball_view ball <= ball* $hype_ballsort_ball  
  • tube $hype_ballsort_tube — также, как и компонент шара, у него будет хранится модель трубки

  • active false — свойство с типом boolean нужно для отображения активации

  • event * click? <=> click? null — биндим свойство click на событие клика

  • rows / — для отображения детей у $mol_list предусмотрено свойство rows, а не sub как у $mol_view

  • <= Roof $mol_view sub / <= roof null — в свойстве Roof будет находится подкомпонент $mol_view, который отображает содержимое свойства roof — оно по умолчанию null. Но при активации трубки roof будет возвращать view-ку шара

  • <= Balls $mol_list — в свойстве Balls подкомпонент на основе $mol_list будет отображать шары в трубке

  • style * min-height \10rem — минимальную высоту указываем через style

  • attr * data-complete <= complete false — чтобы отобразить состояние готово будем использовать data-аттрибут

  • rows <= balls / — у подкомпонента Balls свойство rows заменяем на наше свойство balls которое будет возвращать массив view-шек шаров

Про последнюю часть скажу отдельно.

<= Ball*0 $hype_ballsort_ball_view ball <= ball* $hype_ballsort_ball 

Свойство Ball — это фабрика, которая в сгенерированном классе пометиться декоратором $mol_mem_key. Т.е. она будет создавать и возвращать инстансы view-шек шаров точно также как мы делали это руками в $hype_ballsort_game. Плюс к этому, у созданного инастана будет подменено свойство ball на наше.

Пример из модели:

@$mol_mem_key Tube( index: number ) { const obj = new $hype_ballsort_tube obj.size = () => this.tube_size() return obj } 

А это будет сгенерировано из view.tree описания:

@ $mol_mem_key Ball(id: any) { const obj = new this.$.$hype_ballsort_ball_view()  obj.ball = () => this.ball(id)  return obj } 

Выведим трубку в app:

$hype_ballsort_app $mol_view sub / <= Components $mol_list rows / <= Link $hype_ballsort_link title \Ссылка href \example.com target \_blank <= Button $hype_ballsort_button title \Кнопка <= Ball $hype_ballsort_ball_view <= Tube $hype_ballsort_tube_view balls / <= Ball1 $hype_ballsort_ball_view color_index 2 <= Ball2 $hype_ballsort_ball_view color_index 4 <= Ball3 $hype_ballsort_ball_view color_index 6 

И переопределим у нее свойство balls чтобы увидеть несколько шаров. А чтобы у шаров были разные цвета, у каждого шара переопределим свойство color_index.

На трубку не похоже

На трубку не похоже

Создайте файл tube/view/view.view.css.ts

namespace $.$$ {  $mol_style_define( $hype_ballsort_tube_view, {  // В оригинальном приложении box-sizing = content-box // а у $mol_view по дефолту стоит border-box // поэтому меняем boxSizing: 'content-box', width: 'fit-content',  Roof: { boxSizing: 'content-box', height: '3rem', alignItems: 'center', justifyContent: 'center', border: { bottom: { style: 'solid', color: 'lightgray', }, }, },  Balls: { boxSizing: 'content-box', width: '3rem', flex: { direction: 'column-reverse', }, justifyContent: 'flex-start', alignItems: 'center',  border: { width: '2px', style: 'solid', color: 'lightgray', },  padding: { bottom: '0.4rem', top: '0.4rem', },  borderRadius: '0 0 2.4rem 2.4rem',  '@': { 'data-complete': { true: { // Когда data-complete=true backgroundColor: 'lightgray', }, }, }, },  } )  } 
Теперь что-то похожее

Теперь что-то похожее

В $hype_ballsort_app добавим трубке шар на крышку:

- ... <= Tube $hype_ballsort_tube_view balls / <= Ball1 $hype_ballsort_ball_view color_index 2 <= Ball2 $hype_ballsort_ball_view color_index 4 <= Ball3 $hype_ballsort_ball_view color_index 6 roof <= Ball4 $hype_ballsort_ball_view color_index 8 
Шар на крышке отображается

Шар на крышке отображается

Осталось добавить только поведение, создайте файл tube/view/view.view.ts

namespace $.$$ {  export class $hype_ballsort_tube_view extends $.$hype_ballsort_tube_view {  // Шар на крышке @ $mol_mem roof() { // Получаем индекс последнего шара, напомню что this.tube() возвращает модель трубки // Через фабрику получаем инстанс компонента шара который возвращаем // Или возвращаем null const index = this.tube().balls().length - 1 return this.active() ? this.Ball( index ) : null }  // Массив компонентов шаров, которые будут отображаться в трубке @ $mol_mem balls() { // В зависимости от активности трубки получаем список моделей шаров const last_ball = this.tube().balls().at(-1) const list = this.active() ? [last_ball] : this.tube().balls()  // Превращаем его в список компонентов шаров return list.map((_, index) => this.Ball(index)) }  // Получаем модель шара по индексу ball(index: number) { return this.tube().balls()[index] }  // Вытаскиваем из трубки состояние статуса готово complete() { return this.tube().complete() }  }  } 

title

Создадим подкомпонент для отображения заголовка.

Сам заголовок

Сам заголовок

Его не будем выносить в отдельный модуль. Добавим его как подкомпонент в app.view.tree

$hype_ballsort_app $mol_view sub / <= Components $mol_list rows / - ... <= Title $mol_view dom_name \h2 sub / <= Title_begin $mol_view sub / \BALL <= Title_end $mol_view sub / \SORT  
Посмотрим что получилось

Посмотрим что получилось

Добавим ему стилей, создатйе файл app.view.css.ts

namespace $.$$ {  $mol_style_define( $hype_ballsort_app, {  Title: { font: { size: '3rem', weight: 300, }, },  Title_begin: { textDecoration: 'underline', },  } )  }  
Уже похоже

Уже похоже

app

Теперь мы можем собрать экраны, удалим лишнее из app.view.tree и создадим основную структуру:

$hype_ballsort_app $mol_view game $hype_ballsort_game title \BALL SORT Title $mol_view dom_name \h2 sub / <= Title_begin $mol_view sub / \BALL <= Title_end $mol_view sub / \SORT sub / <= Start_page $mol_list <= Game_page $mol_list <= Finish_page $mol_list 
  • game $hype_ballsort_game — в свойстве game будем хранить инстанс текущей игры

  • title \BALL SORT — то что отобразится в заголовке вкладки

  • Start_page, Game_page, Finish_page заготовки для страниц

Start_page

И давайте сразу оформим стартовый экран:

- ... sub / <= Start_page $mol_list rows / <= Title <= Start $hype_ballsort_button title \Start game click? <=> start? null <= Links $mol_view sub / <= Sources $hype_ballsort_link title \Source Code href \https://github.com/PavelZubkov/ballsort target \_blank <= Game_page $mol_list <= Finish_page $mol_list 
  • Первым у нас выводится Title

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

  • И выводится блок со ссылками в свойстве Links

Посмотрим как это выглядит

Почти стартовый экран

Почти стартовый экран

Давайте добавим недостающие стили в app.view.css.ts, я просто тащу их из оригинального приложения.

namespace $.$$ {  $mol_style_define( $hype_ballsort_app, {  fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif, BlinkMacSystemFont, Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol', color: '#e1e1e1', lineHeight: 'normal',  padding: { top: '1rem', },  justifyContent: 'center',  background: { color: '#101526', },  // Title, Title_begin ...  Links: { padding: { top: '1rem', }, justifyContent: 'center', flex: { wrap: 'wrap', }, },  Start_page: { alignItems: 'center', },  } )  } 
Стартовая страница

Стартовая страница

Game_page

Перейдем к странице игры. Она состоит из трех вертикальных блоков

  • Кнопки управления + вывод количества шагов

  • Трубки с шариками

  • Те же ссылки что и на главной странице

Должно быть что-то такое:

Game_page Control Home - кнопка возврата на стартовый экран Restart - кнопка перезапуска игры Move - число с количеством шагов Tubes - трубки с шариками Links - ссылка 

Добавим это в app.view.tree

$hype_ballsort_app $mol_view - ... sub / <= Start_page $mol_list - ... <= Game_page $mol_list rows / <= Control $mol_view sub / <= Home $hype_ballsort_button title \← click? <=> home? null <= Restart $hype_ballsort_button title \Restart  click? <=> start? <= Tubes $mol_view <= Links <= Finish_page $mol_list 
Два экрана одномервенно

Два экрана одномервенно

Как мы будем определять запущена игра или нет?

Во view.tree у нас объявлено свойство game, которое хранит экземпляр класса игры. Во view.ts мы его переопределим, сделаем изменяемым свойством и по умолчанию оно будет возвращать null. Логика такая:

  • game возвращаем null — показываем стартовый экран

  • game возвращает инстанс игры — показываем экран игры

  • Клик по кнопкам старт и рестарт будет помещать в свойство game новый экземпляр игры

  • Клик по кнопке назад будет помещать null в свойство game

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

Как мы будем менять экраны?

Сейчас у нас все три экрана выведены в свойстве sub. Во view.ts нам надо переопределить свойство sub, чтобы оно в один момент времени возвращался только один, нужный экран.

Создайте файл app.view.ts, помните про снипеты в VSCode, тут нужен снипет logic.

namespace $.$$ {  export class $hype_ballsort_app extends $.$hype_ballsort_app {  // Переопределяем свойство game // Теперь оно изменяемое и nullable @ $mol_mem game(next?: $hype_ballsort_game | null) { return next ?? null! }  // Кнопки start и restart забиндены на свойство start // Тут мы просто помещаем новый инстанс игры в свойство game @ $mol_action start() { this.game( new $hype_ballsort_game ) }  // Кнопка возврата забиндена на свойство `home` // Тут мы помещаем null в свойство game  @ $mol_action home() { this.game(null) }  // Дети компонента $mol_view берутся из свойства sub // Тут мы возвращаем нужный экран в зависимости состояния игры @ $mol_mem sub() { if (!this.game()) return [ this.Start_page() ] return [ this.game().finished() === false ? this.Game_page() : this.Finish_page() ] }  }  } 
Переходы между экранами

Переходы между экранами

Трубки и шары

Теперь пришла очередь отрисовать трубки с шарами. Нам надо взять список трубок из игры и вывести его обернув каждую модель трубки во view-компонент.

Изменим app.view.tree

$hype_ballsort_app $mol_view - ... sub / <= Start_page $mol_list - ... <= Game_page $mol_list rows / <= Control $mol_view - ... <= Tubes $mol_view sub <= tubes / <= Tube*0 $hype_ballsort_tube_view tube <= tube* $hype_ballsort_tube click? <=> tube_click*? null active <= tube_active* false <= Links <= Finish_page $mol_list 

Что тут происходит:

  • <= Tubes $mol_view — мы создаем подкомпонент Tubes на основе базового компонента $mol_view и кладем его в rows / у объекта в свойстве Game_page

  • sub <= tubes / свойство sub у Tubes заменяем на свойство tubes и устанавливаем ему значение по умолчанию

  • А в качестве значение подставляем свойство-фабрику Tube на основе view-компонента трубки, и тут же настраиваем его подменяя свойства tube, click, active

Код выше преобразуется в такой ts-код:

@ $mol_mem Tubes() { const obj = new this.$.$mol_view() obj.sub = () => this.tubes() return obj }  tubes() { return [ this.Tube("0") ] as readonly any[] } 

Нам надо переопределить tubes, чтобы оно брало список трубок из модели игры и оборачивало во view-компонент трубки. Изменим app.view.ts

namespace $.$$ {  export class $hype_ballsort_app extends $.$hype_ballsort_app {  // ...  @ $mol_mem tubes() { return this.game().tubes().map( ( _, index ) => this.Tube( index ) ) }  }  } 
Кликнув на старт мы увидим заполненые трубки

Кликнув на старт мы увидим заполненые трубки

Добавим реализация для свойств tube, tube_click, tube_active, которые мы описали во view.tree

tube <= tube* $hype_ballsort_tube click? <=> tube_click*? null active <= tube_active* false 

Изменим app.view.ts еще раз:

namespace $.$$ {  export class $hype_ballsort_app extends $.$hype_ballsort_app {  // ...  @ $mol_mem tubes() { return this.game().tubes().map( ( _, index ) => this.Tube( index ) ) }  // По индексу достаем инстанс модели трубки из игры // декротар тут можно опустить tube( index: number ) { return this.game().Tube(index) }  // По клику вызываем tube_click в игре // Передавая туда трубку по которой кликнули @ $mol_action tube_click( index: number ) { this.game().tube_click( this.tube(index) ) }  // Проверяем активна ли текущая трубка @ $mol_mem_key tube_active( index: number ) { return this.game().tube_active() === this.tube(index)  } }  } 
Уже можем играть

Уже можем играть

Давайте выведем количество шагов. Изменим app.view.tree

$hype_ballsort_app $mol_view - ... sub / <= Start_page $mol_list - ... <= Game_page $mol_list rows / <= Control $mol_view sub / <= Home $hype_ballsort_button title \← click? <=> home? null <= Restart $hype_ballsort_button title \Restart  click? <=> start? - Тут добавим Moves <= Moves $mol_view sub / <= moves \Moves: {count} - ... <= Finish_page $mol_list 

А во view.ts переопределим свойство movesmoves \Moves: {count}, чтобы оно заменяло {count} на число шагов

namespace $.$$ {  export class $hype_ballsort_app extends $.$hype_ballsort_app {  // ...  @ $mol_mem moves() { return super.moves().replace( '{count}', `${ this.game().moves() }` ) } }  } 
Отображение количества шагов

Отображение количества шагов

И добавим стилей в app.view.css.ts

namespace $.$$ {  $mol_style_define( $hype_ballsort_app, {  // ...  Moves: { padding: ['0.6rem', '0.4rem'], fontSize: '1.3rem', },  Tubes: { justifyContent: 'center', },  Control: { justifyContent: 'center', },  Tube: { margin: '1rem', },  } )  } 
Экран игры

Экран игры

Finish_page

Осталось добавить только экран финиша. Изменим app.view.tree:

$hype_ballsort_app $mol_view - ... sub / - ... <= Finish_page $mol_list rows / <= Control <= Tubes <= Links <= Finish $mol_list rows / <= Finish_title $mol_view dom_name \h1 sub / \You won! <= Finish_moves $mol_view dom_name \h2 sub / \In 16 moves <= Finish_home $hype_ballsort_button title \New game click? <=> home? 

Финишный экран, выводится поверх экрана игры. Мы также выводим Control, Tubes, Links и после финишные надписи и кнопку.

Сразу добавим стилей для него в app.view.css.ts

namespace $.$$ {  $mol_style_define( $hype_ballsort_app, {  Finish: { position: 'fixed', bottom: 0, top: 0, left: 0, right: 0, background: { color: $mol_style_func.rgba(255, 255, 255, 0.6), }, backdropFilter: $mol_style_func.blur('6px'), alignItems: 'center', paddingTop: '5rem', },  Finish_title: { color: 'black', textShadow: '0 0 2px white', },  Finish_moves: { color: 'black', textShadow: '0 0 2px white', margin: { top: '1rem', }, },  Finish_home: { margin: { top: '1rem', }, },  } )  }  
Экран финиша

Экран финиша

Тестируем приложение

Напишем тест, чтобы убедится, что экраны у нас корректно меняются. Создайте файл app.view.test.ts

namespace $.$$ { $mol_test({  "Screan changing"() { const app = new $hype_ballsort_app  // По умолчанию должен показываться стартовый экран $mol_assert_like(app.sub(), [app.Start_page()])  // Кликаем по кнопке старта и проверяем что теперь отображается экран игры app.start() $mol_assert_like(app.sub(), [app.Game_page()])  // Выиграем игру, просто установим всем шарам один цвет и проверим экран app.game().balls().forEach(obj => obj.color(0)) $mol_assert_like(app.sub(), [app.Finish_page()]) },  }) } 
Смотрим консоль в поиске упавших тестов

Смотрим консоль в поиске упавших тестов

Убедимся, что тест работает, сломав его, замените Finish_page на Game_page в последнем ассерте.

Тест работает

Тест работает
  • Полные исходники можно найти тут

  • Приложение здесь

По всем вопросам можно идти сюда.

Актуальный оригинал на $hyoo_page


ссылка на оригинал статьи https://habr.com/ru/articles/741256/