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

Экраны
Start - стартовый экран на котором отображается заголовок, кнопка для запуска игры, и подвал с cсылками
Game - при клике на кнопку запуска, открывается экран с игрой, на котором необходимо сортировать шарики. В хедере находятся кнопки возврата на стартовый экран и рестарта игры, а также счетчик числа сделаyных шагов. В центре трубки с шарами. В подвале те же ссылки что и на первом экране.
Finish - когда шарики отсортированы, поверх второго экрана отображается третий экран. На нем находится заголовок "You won!", количество сделанных шагов, и кнопка "New game" которая открывает стартовый экран.
Механика игры
Рисуются 6 трубок, четыре и них заполнены шарами и две пустые
В заполненных трубках находятся по 4 шара, четырех разных цветов
При клике на непустую трубку, она переходит в активное состояние
В активном состоянии верхний шар в трубке переносится на ее крышку
Повторный клик по активной трубке дезактивирует ее, шар переносится обратно в нее
После активации трубки, клик по другой трубке переносит шар с крышки в другую трубку при условии, что другая трубка пуста или верхний шар другой трубки такого же цвета как шар на крышке активной трубке
Когда в одной и трубок все 4 шара одного цвета она переходит в статус готово, после этого шары в нее/из нее перемещать нельзя.
Игра закончится, когда 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- минимальную высоту указываем черезstyleattr * 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_pagesub <= 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 переопределим свойство moves - moves \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 в последнем ассерте.

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