Привет, Хабр! Меня зовут Марк. Я студент, на протяжение последних двух лет уперто пилю один пет‑проект, который в этом учебном году защищаю как диплом. В статье — как я дошел до идеи сделать собственный симулятор логических схем, и во что он превратился.

Предыстория

Еще в школе на меня сильно повлияла книга Чарльза Петцольда «Код»: впервые стало понятно, как компьютер устроен на самом простейшем уровне: транзисторы логические элементы схемы поведение.

На защите индивидуального проекта в 10 классе мы с другом спаяли 1-битный сумматор на полевых транзисторах: три входа (A, B и перенос с предыдущего бита) и два выхода (сумма и перенос на следующий бит).

Пример логической схемы 1-битного сумматора, собранная в Logicly

Спустя пару лет на паре в универе по булевой алгебре мы собирали схему в веб‑симуляторе. В тот момент щелкнуло: а могу ли я сделать свой симулятор. Зачем? Да, просто! Открыв, на тот момент еще живой, редактор кода Brackets, я накидал скелет проекта. С тех пор он пережил несколько переписей, каждый раз, словно феникс из пепла, становясь сильнее и лучше. Мотивация держится до сих пор.

В этой статье я коротко расскажу:

  • через какие грабли прошел и что открыл по дороге,

  • почему несколько раз приходилось переписывать проект, и чем это помогло,

  • как из «игрушки» на паре выросло ядро с понятной архитектурой,

  • и зачем продолжаю это делать два года.

Желаемые требования

С самого начала я хотел, чтобы симулятор умел:

  • собирать схемы любой вложенности (кастомные блоки внутри блоков),

  • отображать симуляцию в реальном времени c паузой / шагом / скорость,

  • корректно обрабатывать циклы обратной связи (например, NOR, замкнутый на себя), не зависая и не запрещая пользователю их строить.

Пример самоподдерживающегося осциллятора на NOR, собранный в Logicly

Как все начиналось: первая версия на vanilla JS

Старт был типичным. Я умел немного JS, приложений раньше не писал и просто «кодил как вижу». UI — это голый HTML + CSS, бизнес‑логика рядом, все вперемешку. Это тот самый код, за который сейчас очень стыдно, но каждый из нас проходил через это)

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

RS-триггер

Схема умеет сохранять (set - верхний тумблер) и сбрасывать (reset - нижний тумблер) значение на лампе.

Binary-To-Decimal decoder с выводом на 7-сегментный дисплей.

Слева подается сигнал через тумблеры, справа результат вычисления выводится на пины 7-сегментного индикатора. Да, читать эту схему - сущий кошмар, но главное, что она работает!

4-битное ALU

Данное устройство могло складывать и вычитать 4-битные числа, а также воспроизводить логическое И и ИЛИ. Слева располагаются по 4 тумблера для каждого входного числа, с справа сверху 2 тумблера для выбора команды.

Но релизным это не стало бы. Проект родился без опыта — и это было видно. Если всплывал баг, править приходилось в десятке мест. Файлы разрастались до 1500–2000 строк, структура плыла. Конец моего терпения настал при попытке добавить импорт / экспорт схем: я просто утонул в связях.

В итоге я заморозил первую версию. Зато четко понял, что хочу от следующей: границы слоев и слабая связность.

Вторая попытка: Node.js + Arduino

После первой версии мне захотелось, чтобы схема на экране оживала в реальном мире. Я вынес ядро на Node.js и попробовал подружить симулятор с железом. В экосистеме есть библиотека Johnny‑Five: прошиваешь Arduino стандартным скетчем Firmata через Arduino IDE, подключаешься к ней из Node и вуаля — читаешь и пишешь пины как объекты. Так появилось ощущение живости: схема на экране зажигает светодиод на столе.

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

// Взято с официальной документации Johnny-Five
// Минимальный пример: мигаем встроенным LED на 13 пин
const { Board, Led } = require("johnny-five");
const board = new Board();

board.on("ready", () => {
  const led = new Led(13);
  
  led.blink(500);  // Каждые 0.5с менятся состояние: HIGH <-> LOW
});

Но по мере роста стало видно, где я снова упираюсь:

  • Тяжелые инстансы классов. Каждый элемент был объектом класса LogicItem. От 100 000 до миллиона элементов ощущаются серьезные просадки по памяти и времени инициализации. Такие инстансы плохо сериализуются и не подходят для передачи в web worker, чтобы разделить нагрузку.

  • Реальное время важнее сервера. Симуляция — живой процесс: пользователь меняет входы, создает связи, ставит на паузу. Если считать на сервере, появляются лишние сетевые задержки и постоянная рассинхронизация состояния.

  • Ограничение по оборудованию. На моей связке Johnny‑Five нормально «виделась» только одна Arduino: второй пользователь не мог просто подключиться к тому же серверу. Логичный вывод — мост к железу должен запускаться локально у пользователя, а не на общем узле.

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

Третья попытка: когда просто "код" стал системой

Перед тем как снова писать проект, я остановился и признал — мне не хватает базы. Прикупил несколько книжек, изучил кучу статей и видеоуроков как отечественных, так и зарубежных. Освоил TypeScript, углубился в архитектуру клиентских приложений, ООП, SOLID, паттерны. Это дало мне совершенно иное понимание о процессе разработки.

Что я кардинально поменял?

  1. Data‑oriented архитектура.

    Все сущности — это простые POJO (Plain Old JavaScript Objects), не знающие о логике. Все поведение вынесено в инструменты и сервисы, которые работают с этими объектами.

  2. Ядро живет в веб‑воркере.

    UI — тонкий слой: показывает состояние и шлет команды. Вся обработка данных — внутри воркера. Это убирает лаги в интерфейсе и готовит почву для «тяжелых» проектов.

  3. Монорепо и пакеты с четкой ролью.

    • schema — доменные типы / контракты: Item, Scope, Template, Args и так далее Источник правды; без зависимостей.

    • helpers — предметные утилиты: buildLinkId, pinHelpers, saveChildToScope. Они знают про доменные типы и работают с ними.

    • utils — общие утилиты: toArray, flatValues, утилиты для работы с деревьями, проверки, мелкие функциональные штуки.

    • di — легкий собственно‑писанный контейнер зависимостей.

    • simulation — отдельный модуль, который можно вынести как на сервер для просчета тяжелых вычислений, так и подключить напрямую к ядру.

    • entities-runtime — слой абстракций. Тут живут базовые интерфейсы и классы, на которых держится остальная система. Этот пакет не зависит от конкретных реализаций — его можно переиспользовать хоть в тестах, хоть в других проектах.

      // Минимальный пример контракта хранилища
      // K - тип ключа, V - тип значения
      export interface CrudStore<K, V> {
        get(key: K): V | undefined;
        insert(key: K, value: V): void;
        update(key: K, fn: (prev: V) => V): boolean;
        remove(key: K): V | undefined;
        readonly size: number;
      }
      
      // Логика
      class InMemoryCrudStore<K, V> implements CrudStore<K, V> {
        // реализация методов
      }
    • modules-runtime — слой реализаций поверх абстракций из entities-runtime. Конкретные модули ядра:

      • ItemStore, LinkStore, TemplateStore, ScopeStore — типизированные хранилища под конкретные данные.

        // Пример использования 
        const itemStore = new InMemoryCrudStore<Id, Item>();
        
        itemStore.insert(item.id, item);
        const removed = itemStore.remove(item.id);
      • ItemFactory, ScopeFactory — создают сериализуемые сущности.

      • ItemComputeService — модуль вычисления выходов элементов.

    • engine — оркестратор ядра. Управляет всеми модулями и их связями:

      • поднимает DI‑контейнер, регистрирует конкретные реализации из modules‑runtime,

      • собирает и экспортирует use‑case API,

      • разворачивает event-bus (шину событий) и дает возможность регистрации плагинов,

      • отвечает за жизненный цикл модулей.

    Такой подход позволил сделать код модульным и изолированным: каждая часть отвечает только за свою задачу.

  4. Use-cases как язык общения с ядром.

    Все взаимодействие с движком идет через дерево типизированных команд. У них есть два уровня видимости:

    • public — то, что доступно снаружи в пользовательском API,

    // инициализируем движок
    const engine = new Engine();
    
    // Создание вкладки
    const tab = engine.api.tab.create();
    
    // Создание элемента AND внутри вкладки
    const item = engine.api.item.create({
      kind: "base:logic", 
      hash: "AND", 
      path: [tab.tabId]
    });
    
    // запускаем симуляцию на 16 тиков
    const simData = engine.api.simulation.start({ticks: 16});
    
    • internal — служебные команды, видимые только внутри других use‑cases (для композиции, повторного использования без «засорения» публичного API).

    // где-то внутри public use-case вызываем internal 
    
    factory: (ctx: UseCaseCtx) => {
      /* code */
      const res = ctx.api.item.createSingle(/* args */);
      /* code */
      return res;
    },

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

  5. Типобезопасный event‑bus. События именую как модуль.подмодуль.тип — например, api.useCase.start. Можно подписываться точечно или «звездочками» сразу на группу.

    // все события ядра
    bus.on("*.*.*", ({event, payload}) => { /* code */ });
    
    // все события API
    bus.on("api.*.*", ({event, payload}) => { /* code */ });
    
    // все ошибки ядра
    bus.on("*.*.error", ({event, payload}) => { /* code */ });
    
    // конкретное событие — use-case завершился
    bus.on("api.useCase.finish", ({payload}) => { /* code */ });
  6. Плагинная система для расширения ядра, не трогая ядро.

    Смысл здесь прост: не каждый захочет разбираться в чужих кишках. Плагин позволяет добавить или переопределить use‑cases и сервисы, подключать обёртки (wrappers) вокруг существующих команд, а также выполнять код при запуске движка через функцию setup().

    6.1. Добавляем публичный use-case в API

    UseCaseToken — Это типизированный ключ для команды API. Он фиксирует сигнатуру функции (payload => result) и ее видимость. Плагин через declaration merging добавляет новый токен в общий контракт плагинов — и движок начинает «видеть» новый публичный use‑case.

    // пользователь переопределяет контракт для плагинных use-cases
    declare module "@engine/api" {
        interface IPluginsApiSpec {
            // задаем сигнатуру функции и область видимости use-case
            log: UseCaseToken<{ (a: string): string }, "public">;
        }
    }
    
    // хелпер инициализации плагина
    const MyPlugin = definePlugin("MyPlugin", {
      
        // Плагин предоставляет для этого два хелпера и набор токенов
      
        api: ({ mkToken, mkConfig, tokens }) => ({
          
            // Кладем токен в нужный нам слой
            // Так, мы получим путь api.plugins.log
          
            spec: {
                log: mkToken.public("logger"),
            },
          
            // В конфиге прописываем логику нашего use-case
          
            configs: [
                mkConfig({
                    token: tokens.plugins.log, // соединили с токеном
                  
                    factory: () => {
                        const log = ((payload) => {
                            return `$I got it: "{payload}"`;
                        }) satisfies { (a: string): string };
    
                        return log;
                    },
                }),
            ],
        }),
    });
    
    // Пример регистрации
    const engine = await Engine.use(MyPlugin).build();
    
    const log = engine.api.plugins.log('Hello World!'); // `I got it: "Hello World!"`

    6.2. Добавляем свой сервис в dependencies

    DepsToken — это типизированный ключ для зависимости в DI‑контейнере. Аналогично, плагин через module augmentation добавляет новый токен и регистрирует под ним реализацию.

    // пользователь переопределяет контракт для плагинных dependencies
    declare module "@engine/di" {
        interface IPluginsDepsSpec {
            metrics: DepsToken<MetricsContract>;
        }
    }
    
    // хелпер инициализации плагина
    const MyPlugin = definePlugin("MyPlugin", {
        deps: ({ mkConfig, mkToken, tokens }) => ({
    
            // Кладем токен в нужный нам слой
            // Так, мы получим путь deps.plugins.metrics
          
            spec: {
                metrics: mkToken("metrics"),
            },
    
            // В конфиге указываем конструктор класса, который будет вызван
            // Или пишем свою фабрику, если ваш модуль требует зависимости ядра
          
            configs: [
                mkConfig({ 
                    token: tokens.plugins.metrics, 
                    useClass: MyMetrics,
                  
                    // или так, если нужны зависимости движка
                    useFactory: (get) =>{
                        return new MyMetrics({
                            bus: get(tokens.core.bus)
                        })
                    }      
                }),
            ],
        }),
        // теперь внутри любого use-case в ctx.deps мы видим нашу метрику
        api: (...) => ({
            spec: { /* token */ }
            configs: [
                mkConfig({
                    token: /* token */,
                    factory: (ctx) => { // контекст use-case
          
                        const { metrics } = ctx.deps.plugins;
                        metrics.method // вызываем какой-то метод сервиса 
                       
                        /* дальнейшая use-case реализация */
                    },
                }),
            ],
        }),
    });

    6.3. Wrappers: сквозная логика без правок ядра

    Wrapper (aka middleware)— это обертка вокруг use‑case, которая добавляет сквозное поведение (логирование, метрики, подсчет времени и т.п). Их можно применить глобально ко всем use‑cases или локально на конкретный.

    • Общий конструктор:

    const myWrapper = defineWrapper("my-wrapper", (ctx, next) => {
      // ctx — контекст выполнения use-case: deps, api, meta.
      // next(payload?) — продолжить цепочку (можно изменить payload)
      // вернуть можно результат next(...) или свой, если вы «перехватываете» выполнение
    });
    • Подключение глобального wrapper в плагине:

    // инициализируем wrapper, считающий время выполнения use-case
    const timingWrapper = defineWrapper("timing", (ctx, next) => {
      const t0 = performance.now();
      
      const res = next(); // идём дальше по цепочке
      
      const dt = Math.round(performance.now() - t0);
    
      // выводим имя use-case, которое задали руками
      console.log(`${ctx.meta.useCaseName}: ${dt} ms`);
      return res;
    });
    
    // подключаем в плагин
    const MyPlugin = definePlugin("MyPlugin", {
      wrappers: [timingWrapper],
    });
    
    // вызываем любой use-case
    engine.item.create(...);
    
    // в консоли видим: "createItem: 0.05 ms"
    • Подключение локального wrapper в плагине:

    // Из примера добавления use-case
    const MyPlugin = definePlugin("MyPlugin", {
        
        api: ({ mkToken, mkConfig, tokens }) => ({
            spec: { /* token */ },
            configs: [
                mkConfig({
                    token: /* token */
                    factory: () => { /* use-case */ },
                    wrappedBy: [timingWrapper] // локальная обертка
                }),
            ],
        }),
    });

    6.4. Setup()

    Вызывается после того, как движок собрал DI, зарегистрировал все зависимости и use‑cases. Это точка, где плагин может, например, подписаться на все события.

    const MyPlugin = definePlugin("MyPlugin", {
      setup: async ({ deps }) => {
        const bus = deps.core.bus; // получаем шину событий
    
        // выводим в консоль все события API и их payload
        bus.on('api.*.*', ({event, payload}) => {
          console.log(event, payload);
          
          // можем даже куда то сохранять, чтобы через определенный use-case
          // выдавать целый пакет событий.
          
          deps.plugins.EventManager.save(event, payload);
        })
      }
    })

UI: что задумано и переосмыслено

UI я планирую писать на фреймворке SolidJS — он отлично подходит под идею реактивного интерфейса, где все обновляется по событиям из ядра. Часть кода клиента у меня уже написана, но она требует серьезного рефакторинга, чтобы привести ее в соответствие с текущей версией движка.

Параллельно в свободное время от программирования, я занимаюсь проектированием интерфейса рабочего пространства и дизайна элементов.

Прототип интерфейса Gately

Интерфейс не полный, но уже примерно дает небольшое понимание о количестве и расположении блоков.

Прототип окна инвентаря элементов
Прототип окна создания кастомной схемы
Дизайн логических элементов
Прототип дизайна таблицы истинности

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

В будущем я хочу развивать нодовую систему элементов, чтобы через соединения можно было передавать не только логические значения, но и более сложные объекты. Это откроет путь к новым типам элементов, которые смогут обрабатывать результаты симуляции: проверять корректность работы схем, находить ошибки, сравнивать ожидаемые и фактические значения, строить булевые функции и, наоборот, схемы по ним.

Где я сейчас

Если коротко, ядро доведено до состояния MVP под мои требования. Оно уже решает ключевые задачи и при этом остается расширяемым: создание кастомных схем, создание / удаление связей, элементов и вкладок, симуляция на N тиков.

Если я пойму, что вокруг моего проекта появляется интерес от аудитории, я обязательно найду и потрачу время, чтобы в ускоренном темпе выложить его в open‑source. Для этого нужно отполировать некоторые стыки и экспорты.

Заключение

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

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

Спасибо за внимание!

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


  1. user-book
    12.11.2025 09:23

    выглядит вкусно. если это все будет в вебе, причем схемами можно делится без лишних телодвижений (прямая ссылка) то будет прям огненный огонь

    за СНГ не знаю, но глобально в мире прям именно такого простого, не лаганого и наглядного с эмуляцией нет


    1. MarkBrosalin Автор
      12.11.2025 09:23

      Спасибо большое! Для веба есть несколько популярных редакторов, такие как: Logicly, CircuitVerse или Simulator.io, но каждый из них имеет свои плюсы и минусы, по-моему скромному мнению.

      Если не знакомы, то советую хотя бы опробовать, чтобы сложилось собственное мнение. Из всех трех, мой фаворит - это первый, но там нельзя делиться схемами в бесплатной версии :( И, в отличие, от CircuitVerse нельзя устанавливать задержки элементам. А в simulator.io для меня не привычный UX/UI.

      Сравнивать свой симулятор с ними, мне совесть не позволяет, потому что их разработка длилась годами, а, например, CircuitVerse до сих развивает целая команда.

      Спасибо за поддержку!


      1. user-book
        12.11.2025 09:23

        я про это и говорю)

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


  1. acomplex
    12.11.2025 09:23

    Вот такой ещё для примера можно посмотреть https://sebastian.itch.io/digital-logic-sim


    1. MarkBrosalin Автор
      12.11.2025 09:23

      LogicSim и его автору большое почтение! На его уроках в ютубе я учился :) Замечательная программа!