Попытка реализовать известный модуль CRT, используемый в Pascal, в JavaScript. Что из этого получилось, а что нет, расскажу.

Вступление


Мое знакомство с программированием началось еще в 8 классе, когда я впервые узнал на уроке информатики, что такое Pascal и какие он дает возможности. Тогда на школьных компьютерах был установлен Turbo Pascal, хотя учитель информатики давно хотел поставить туда PascalABC.NET. Безусловно, начиналось все с банальных выводов строки в консоли, моя деятельность в основном была направлена на отличную подготовку к ОГЭ. Никаких модулей не изучалось, ибо на экзамене этого никто и не требовал.

Но даже тогда, когда я мог себе «подчинить» консольное окно, выводить все, что туда захочется, производить вычисления, принимать ввод от пользователя, я был удивлен, насколько это круто!

Но время идет, жизнь меняется: ОГЭ сдано, ЕГЭ пройдено, успешное поступление в ВУЗ. Всё это время я с огромным интересом изучал новые языки, в результате чего уже могу спокойно писать сайты, будь это front или back. Почему-то веб-программирование меня больше всего интересует.

Как дошло дело до CRT


Еще в школьные годы я изучил один из интересных модулей Pascal под названием CRT. На самом деле в нем нет ничего сложного, набор команд, по сути, маленький, но они позволяли в консольном окне творить новые вещи: перемещать курсор по экрану размером 80x25 (размер экрана DOS), менять цвет фона и текста, воспроизводить звук определенной частоты и продолжительности. На нем можно было создавать полноценные ASCII игры, которые практически не занимали место на жестком диске в силу их маленького размера.

И сейчас, спустя несколько лет, я решил, исходя из интереса, написать небольшой js файлик, при подключении которого с окном браузера можно работать как с окном консоли. Скажу сразу, что восстановить в целостности и сохранности все команды модуля очень сложно, если вообще возможно. Все-таки JavaScript это не Pascal, из-за этого здесь есть некоторые особенности.

Сама идея


Проект имеет очень простую структуру из трех файлов:

  • crt.js — файл с функциями, который нужно подключить к html файлу
  • index.html — файл — основа, который следует открыть в браузере
  • user.js — пустой файл, в котором программист должен писать свой код

Сначала назову команды, которые на данный момент реализованы, а затем покажу, как они работают.

Реализованные команды:

  • gotoxy(x,y) — перемещение курсора в координаты
  • write(str) — вывод строки на экран
  • clrscr() — очистка экрана выбранным заранее фоном и перемещение курсора в координаты 1,1
  • textcolor(int) — смена цвета текста
  • textbackground(int) — смена цвета фона
  • sound(fr,1000) — воспроизвести звук частотой fr и продолжительностью 1 секунда

Давайте покажу пример работы «модуля»:

Код:

image

Результат:

image

Вам может показаться, что буквы стоят отдельно друг от друга. Да, так и есть. Дело в том, что здесь содержимое страницы поделено на части div'ами. Давайте вспомним размер окна DOS (80x25). Значит, сколько здесь div'ов? Правильно, 2000. Каждый из них имеет равный размер. Вообще, при запуске страницы автоматически выполняется следующая функция:

image

Я специально повесил эту работу на JS. Я хотел, чтобы в html файле было чисто и понятно.

image

Да, из-за такой схемы есть эффект, при запуске страницы на слабеньком ПК, думаю, секунд 4-5 будет происходить только загрузка, ибо цикл довольно сложный. Комментировать каждую строку не вижу смысла, на фото основные действия объяснены. Каждый раз генерируем div с определенными id и параметрами и добавляем его в body. Каждый div содержит только один символ, как это было по аналогии в DOS (одна ячейка — один символ).

Работа с координатами и цветами основана на этих переменных:

image

Команды gotoxy(x,y), textcolor(int), textbackground(int) просто меняют содержимое переменных xnow, ynow, color, bgcolor.

С цветами есть интересные моменты. В DOS, как мы знаем, можно было выбирать цвет из набора, в котором было всего 16 цветов. В Pascal можно обращаться к цвету с помощью номера (0-15). Причем в DOS'е фон избирался только из первых восьми цветов, а текст из всех 16. Тогда как уже в Windows в PascalABC.NET при подключении модуля фон можно менять из всех 16 цветов. Возможно не все поняли, что я хотел сейчас донести, но давайте поясню на примере:

image

Здесь перечислены все цвета, которые используются в консоли. Если мы попробуем поменять фон в DOS'е на светло-зеленый (10), то background станет зеленым (2), тогда как шрифт станет того цвета, которого мы требовали. Почему-то возможности изменения фона в DOS'е (Free Pascal) ограничены восемью цветами.

А теперь о команде clrscr, которая очищала экран определенным цветом. В JS я её реализовал так:

image

Здесь нет ничего сложного. Мы циклом проходим все div'ы, где в каждом содержимое делаем пустым (так как в DOS символы стираются) и меняем фон на цвет, выбранный заранее командой textbackground. И, конечно, не забываем вернуть курсор в положение 1,1 (левый верхний угол окна).

Самое интересное — это вывод строки командой write. Да, я помню, что есть еще writeln, но посчитал, что будет достаточно и одной команды, так как в данной ситуации перевод курсора на новую строку нас не интересует.

Реализация:

image

Здесь нужно было уберечь браузер от ошибки, в случае, если пользовательская строка собиралась выйти за границы окна (а там div'ов нет!). Поэтому было решено сделать цикл с защитой break.

Каждая буква должна занимать полностью отведенную ей ячейку, поэтому размер шрифта регулируется размером div. Плюс не забываем, что фон за буквами должен меняться на тот, что был установлен командой textbackground.

И, наконец, последняя функция — это sound. Здесь, к сожалению, пришлось изменить схему работы команды, так как реализовать цепочку sound — delay — nosound сложно. Кстати, я не смог пока реализовать delay, идей нет, так как setTimeout здесь не подходит.

Мы помним, что для вывода, например, звука частотой 200 Гц и продолжительностью 1 секунда нужно написать код:

sound(200);
delay(1000);
nosound;

В JS пришлось сделать так:

image

Но зато это работает! Реализация:

image

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

Правда при использовании данной функции Chrome по понятным причинам начинает ругаться:

image

Я попытался решить проблему с помощью setTimeout, однако это не всегда работает.

Но если быстро покликать по странице во время загрузки, то звук воспроизводится, но это уже совсем другая история.

Заключение


Даже после активного верстания сайтов хочется попробовать написать что-то необычное, даже если это не имеет практической пользы. Pascal с его модулем CRT действительно в свое время оказал на меня эффект, который сподвиг меня на дальнейшее изучение языков программирования. А может, стоит написать что-нибудь в стиле ASCII?

На всякий случай выложил это на GitHub

Комментарии (43)


  1. tmnhy
    03.02.2019 22:41
    +2

    Два вопроса:

    Вам может показаться, что буквы стоят отдельно друг от друга. Да, так и есть. Дело в том, что здесь содержимое страницы поделено на части div'ами.

    Вы, что-нибудь слышали про моноширинные шрифты или про flexbox?

    И второй, зачем на JS писать так же как на Pascal?


    1. tyomitch
      03.02.2019 23:07
      +1

      И второй, зачем на JS писать так же как на Pascal?

      Теоретически — могут быть старые паскаль-приложения, которые хочется спортировать в браузерные.

      Автору в качестве направления будущей деятельности предлагаю состыковаться с компилятором Паскаля в JS, чтобы получать на входе программу с «uses crt;» и выполнять её в браузере целиком. И запостить вместе с этой статьёй в «Ненормальное программирование».



      1. stepigor Автор
        04.02.2019 17:10

        Интересное и забавное предложение, спасибо. Боюсь, что второй раз сообщество не будет готово читать про старый, но добрый Pascal c модулем CRT.


    1. stepigor Автор
      04.02.2019 17:07

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

      А что касается второго вопроса, то я еще в статье упомянул, что практического применения у таких разработок, по сути, нет. Хотя выше меня логично предположили, что, подработав модуль, можно как-то упростить портирование или написание новой ASCII игры, хотя не сомневаюсь, что есть игровые библиотеки JS, которые лучше с этим справятся.


      1. tmnhy
        04.02.2019 17:41

        Суть второго вопроса не в этом, а в том, что не надо писать на другом языке как на Паскале, даже, если язык это позволяет, clrscr на Js как-то так должен выглядеть:

        [...document.getElementsByClassName('crt_buffer')].forEach(item => {
          item.innerHTML = '';
          item.style.background = 'black';
        });
        


        Это если не обращать внимания на то, что надо отделять данные от их отображения. Т.е. достаточно одномерного буфера для хранения символов с атрибутами и функции, отображающей этот буфер. Без всяких вложенных циклов.


  1. CoolCmd
    03.02.2019 22:44
    +2

    Почему-то возможности изменения фона в DOS'е (Free Pascal) ограничены восемью цветами.

    один бит отвечает за мигание символа. но это настраивается.

    и почему div, а не table?


    1. staticlab
      03.02.2019 22:57
      +6

      Вообще-то можно было сразу на канвасе рисовать. Будет удобнее и аутентичнее. И, само собой, шрифтом fixedsys, который можно взять отсюда.


      1. shpaker
        03.02.2019 23:14

        Я бы выбрал таблицу всё же. Ну или сделал возможность выбора куда выводить этот псевдотерминал. Канвас сделает как минимум невозможным выделение текста, что будет временами напрягать.


        1. VolCh
          03.02.2019 23:38

          1) не невозможным, а надо будет реализовывать
          2) не помню, чтобы CRT поддерживал выделение


          1. slonpts
            04.02.2019 04:38

            И выделения весьма не хватало в CRT.
            Где тикет, за который можно проголосовать?


          1. shpaker
            04.02.2019 09:51

            1) Зачем изобретать свой велосипед если в браузере это уже и так работает из коробки?
            2) Прям тоска была без этого


            1. Kuorell
              04.02.2019 12:17
              +1

              Например затем, что рисование на дивах/ячейках таблицы — тоже велосипед, но со стрельбой из пушки по воробьям, что сильно бьет по производительности у автора, да и выделение на таблицах будет работать с сюрпризами
              Насколько я помню эмулятор терминала в том же vs code тоже на канвасе, причем его специально на канвас переписали.


      1. CoolCmd
        04.02.2019 00:31

        в таблице мигание символов и курсора можно довольно просто сделать стилями.


        1. Cawabunga
          04.02.2019 17:18

          Не все так просто github.com/Microsoft/vscode/issues/22900


      1. alecv
        04.02.2019 00:38

        Попробуйте GlassTTY
        www.sensi.org/~svo/glasstty


      1. CoolCmd
        05.02.2019 13:30

        не понял, чем аутентичнее?

        и как потом из canvas считывать записанные символы? (не помню, есть ли такая функция в CRT, в BIOS была)

        Будет удобнее

        в таблицу записывать удобно:

        let cell = document.getElementById('video-memory').rows(y).cells(x);
        cell.textContent = char;
        cell.className = `back-color-${attrib >> 4} fore-color-${attrib & 0xf}`;
        


        если сравнивать производительность в 80х50, то многое зависит от реализации canvas в браузере.


        1. staticlab
          05.02.2019 14:56

          Производительность канваса в любом случае будет выше, поскольку это только отрисовка без relayouting. Что касается интерфейса чтения-записи символов, то лучше инкапсулировать его в отдельном модуле, где будет храниться массив выводимых символов.


  1. Scf
    04.02.2019 00:57
    +3

    Идея достойная, т.к. паскаль с Crt и Graph обладает одним замечательным, незаменимым в обучении программированию, свойством — простотой. Начинающих (особенно маленьких) программистов важно увлечь, показать им быстрый результат. Вот в каком современном языке без многобуквенной обвязки можно сделать бегущую строку на экране? Нарисовать зеленое поле, синее небо и желтое солнце?


    1. GooRoo
      04.02.2019 12:00
      +1

      SmallBasic


    1. b_t
      04.02.2019 15:44

      Processing почти идеален для рисования.


  1. justboris
    04.02.2019 03:42

    Вместо того чтобы рисовать терминал самому, дивами, можно взять уже готовый: github.com/xtermjs/xterm.js


  1. CoolWolf
    04.02.2019 03:47
    +1

    Здравствуйте, Игорь! Простите меня за занудство, но хочу указать на некоторые недостатки вашего модуля:

    1. Все ваши переменные и функции находятся в глобальном пространстве имён, что в реальном приложении может привести к большим неприятностям, особенно с учетом весьма распространенных названий вроде «bgcolor» или «loading». Я бы советовал прочитать о паттерне «модуль», он широко распространен в JS.

    2. Вы не используете строгий режим ("use strict"), поэтому не знаете о некоторых потенциально проблемных местах в коде, например, вот здесь вы неявно объявляете глобальную переменную.

    for (k = 1; k <= 80; k++) 


    3. Вы многократно выполняете поиск одного и того же элемента по id. Браузер, конечно, достаточно умён, чтобы выполнять эту выборку быстро, но код выглядит странно и громоздко. Лучше выбрать нужный элемент и сохранить в переменную.
    document.getElementById('x' + (xnow + k) + 'y' + ynow).style.background = bgcolor;
    document.getElementById('x' + (xnow + k) + 'y' + ynow).innerHTML = str.charAt(k);


    4. Советую немного почитать на тему множественной вставки элементов на страницу. Например, вот здесь. Потому что генерация большого количества DOM-элементов в цикле может приводить к просадкам производительности скрипта. Но вообще, выше вам уже подсказали, что лучше в такой задаче использовать элемент Canvas и рисовать всё на нём :)

    5. По поводу вашего вопрос о delay
    function delay(ms) {
      //нужна идея
    }

    могу посоветовать начать копать в сторону генераторов и вообще учиться работать с асинхронным кодом.

    Успехов в вашем начинании! :)


    1. justboris
      04.02.2019 04:01

      Согласен с комментарием во все, кроме последнего пункта:


      могу посоветовать начать копать в сторону генераторов и вообще учиться работать с асинхронным кодом.

      Лучше сразу читать про асинхронные функции. То что их раньше делали через генераторы, это временное решение и костыль.


      Кстати, а еще синхронный delay можно реализовать так:


      function delay(ms) {
         const untilTime = Date.now() + ms;
         while(Date.now() < untilTime) {}
      }

      Решение ужасное, загрузит поток почем зря, но работать будет.


      1. CoolWolf
        04.02.2019 04:21

        async \ await всего лишь высокоуровневая абстракция над генераторами :)
        Да и вроде как задача — реализовать вызов delay(), а не await delay();


        1. Cerberuser
          04.02.2019 05:15
          +1

          А разве это самое delay() (без await или yield в точке вызова) вообще возможно сделать, не подвешивая основной поток?..


          1. Danik-ik
            04.02.2019 09:43

            Например, сделать это внутри delay?
            function delay(timeout) { await asyncDelay(timeout); }
            Подвешивание потока является, конечно, аутентичным поведением, но лучше всё же его эмулировать без сопутствующих потерь.


            1. Cerberuser
              04.02.2019 10:27

              Угу. И delay будет возвращать Promise, с которым в основном потоке всё равно придётся что-то делать.


            1. LEXA_JA
              04.02.2019 10:40

              Так, к сожалению, сделать нельзя. await можно использовать только в контексте async функции. Есть такой вариант с await верхнего уровня: proposal-top-level-await. В хроме это уже можно использовать в консоли, на счет обычного варианта не уверен.


              1. CoolWolf
                04.02.2019 14:05

                Ох, вы правы, с генераторами вместо await delay() будет yield delay(), не знаю, какое помутнение рассудка случилось у меня в 4 утра :)

                Поэтому, видимо, или тормозить основной поток, как предлагал justboris, или вообще писать интерпретатор, а не просто аналоги функций из CRT.


      1. DistortNeo
        04.02.2019 17:09

        Решение ужасное, загрузит поток почем зря, но работать будет.

        Кстати, примерно так оно и работало в Паскале — через busy wait.


    1. Aingis
      04.02.2019 11:49

      Потому что генерация большого количества DOM-элементов в цикле может приводить к просадкам производительности скрипта.
      Это вряд ли, браузеры достаточно умны чтобы оптимизировать такие моменты. Где-то были даже замеры, что DocumentFragment ничем не помогает в плане скорости. Просадки могут быть, если где-то между изменениями DOM идут запросы к характеристикам элементов вроде offsetHeight, которые вынуждают браузер применить предыдущие операции и сделать перерасчёт стилей элементов.


      1. CoolWolf
        04.02.2019 13:50

        Это вряд ли, браузеры достаточно умны чтобы оптимизировать такие моменты.

        DocumentFragment в современных браузерах может замедлять добавление элементов, это факт. Но это не единственный способ реализовать отложенную вставку. По ссылке в моём комментарии можно запустить бенчмарк и пример кода. Он показывает отличия в скорости работы почти в 2 раза на Firefox 65.0, а вот в Chrome разницы практически нет.


    1. F0iL
      04.02.2019 15:24
      +1

      Кстати, с помощью асинхронных функций автор сможет реализовать не только нормальный delay, но еще и работу с клавиатурой (keypressed реализуется и без этого, а вот readkey уже потребует промиса, так как в оригинальном crt он блокирующий).


    1. stepigor Автор
      04.02.2019 17:22

      Спасибо за развернутый комментарий с пояснениями!


    1. Evir
      04.02.2019 17:33

      На Хабре недавно была статья, в которой для соревнования программистов (бои юнитов 4?4, если я правильно запомнил) была реализована песочница в worker thread, и там весь секрет был в том, что сначала отрабатывала функция (и сохраняла последовательность команд в буфер), а уже потом выполнялся код. Возможно, именно такой подход сюда нужен – тогда первые два пункта вроде не имеют значения, ну и пятый можно будет без проблем реализовать.
      Т.е. сначала в воркере запускаем функцию, причём с возможностью убить поток, если он не завершится за вменяемое время. Получаем массив с отработавшими командами, и выполняем их по порядку. Ну и там уже куча несложных вариантов по обработке – например, изначально крутимся в setInterval, выполняя команды до следующего delay (затем ожидая нужного момента времени для продолжения), или просто «встретив» delay записываем номер текущей команды и ставим setTimeout на текущий метод с последующим return.


  1. vladkorotnev
    04.02.2019 04:19
    +1

    Вот это заморочки, напомнило мне про один весьма старый эксперимент, который реализовывал что-то подобное, только со своим языком до кучи. В итоге под него получилось написать даже какой-никакой ДОС и интерпретатор скриптов от Steins;Gate Hen'ikuukan no Octet :-)

    Синтаксис, правда, получился весьма диким и ужасным… Да и вообще концепция до конца не ясна, в плане, нафига оно было надо, и тем не менее оно есть.

    Пример прокомментированного куска кода из интерпретатора
    -- Процедура записи файла сохранения состояния игры
    -- Вызов: sg_save ##номер_слота##
    subp opcode sg_save
    -- Создание объекта для снапшота
    mkobj _save_temp
    -- Запись глобальных переменных состояния в снапшот
    opropw _save_temp "flags" flags
    opropw _save_temp "tflags" tempflags
    opropw _save_temp "scenario" sg_script_name
    -- Чтение файла с сохранениями из "ПЗУ"
    romr "sg_save_data" sg_save_data
    -- Если нет, создать новый
    cno sg_save_data ~assign sg_save_data []`
    -- Получить номер слота из аргумента, максимальный доступный из файла
    assign _ptr `pop`
    assign _max `alen sg_save_data`
    -- Если слот ? максимального, дописать в конец, иначе записать поверх
    chk `geqt _ptr _max` ~aiap sg_save_data _save_temp`
    cno `geqt _ptr _max` ~aiwr sg_save_data _ptr _save_temp`
    -- Сохранить файл в "ПЗУ"
    romw sg_save_data "sg_save_data"
    -- Удалить из ОЗУ объект снапшота
    mkill "_save_temp"
    -- Если слот больше чем максимальный в файле, использовать максимальный
    chk `geqt _ptr _max` ~assign _ptr _max`
    -- На всякий случай сохранить также системные флаги
    csp sg_save_sys
    -- Вывести сообщение об успешном сохранении в слот _ptr
    prnt sg_localized_save_done _ptr
    -- Удалить ненужные объекты из памяти
    mkill "_ptr"
    mkill "_max"
    mkill "sg_save_data"
    -- Возврат из процедуры
    ret


  1. trapwalker
    04.02.2019 12:42

    Какой кошмар!
    А что, нельзя было просто сделать виджет с тегом `pre` и моноширинным шрифтом внутри?
    К тому же 80*20 это не единственный текстовый режим был, были и другие.


    1. F0iL
      04.02.2019 15:21

      Фишка CRT в том, что он позволяет не просто выводить текст, а выводить его с разными цветом и, что важнее, разным цветом фона каждого символа. Поэтому одним pre с моноширинным шрифтом тут не обойтись, все равно придется городить или div, или span, или table.


      1. trapwalker
        05.02.2019 09:25

        Тогда уж проще на канвасе рисовать. Прелесть CRT в его быстродействии. В то время все давно мечтали о нормальных графических режимах, но желехо позволяло только всякий изврат вроде CGA с геморроем переключения страниц памяти, непонятного кодирования цвета битами, необходимостью устанавливать атрибуты не на отдельны йпиксель, а на целую небольшую область… Игроделы умудрялись и из этого конфетку иногда делать конечно, но текстовый режим был прекрасен своей относительной скоростью (если, конечно, пользоваться Int10H для прокрутки, а не вручную перерисовывать символы).
        Здесь же мы видим какой-то сюр. Причем при желании сделать прокрутку области буффера мы, я уверен, получим заметные тормоза из-за всех этих ваших дивов. Это не из пушки по ворробьям, нет, это урановым ломиком в зубах ковыряться. Трудно, больно, вредно, но хочется же.


  1. F0iL
    04.02.2019 17:16

    Можете попробовать после инициализации проверять условие (audioCtx.state === 'suspended') и если оно случилось, то выводить поверх страницы слой с предложением пользователю ткнуть по элементу (кнопке) для того, чтобы активировать звук, и уже в обработчике события делать audioCtx.resume.

    И да, завязываться на 80x25 в коде каждого метода я бы не стал, вынесите это в константы, магические числа — злой антипаттерн :)


    1. stepigor Автор
      04.02.2019 17:40

      Да, Вы правы! Спасибо за комментарий!


  1. morsic
    04.02.2019 17:25

    Для sound можно применить такую схему
    1) сначала выполняем весь код, но изменения не применяем, т.е. по сути только эмитим экшены — их порядок запоминаем
    2) выполняем все это в том же порядке

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