Gately — мой симулятор логических схем: от «игрушки» к диплому
- четверг, 13 ноября 2025 г. в 00:00:06
Привет, Хабр! Меня зовут Марк. Я студент, на протяжение последних двух лет уперто пилю один пет‑проект, который в этом учебном году защищаю как диплом. В статье — как я дошел до идеи сделать собственный симулятор логических схем, и во что он превратился.
Еще в школе на меня сильно повлияла книга Чарльза Петцольда «Код»: впервые стало понятно, как компьютер устроен на самом простейшем уровне: транзисторы → логические элементы → схемы → поведение.
На защите индивидуального проекта в 10 классе мы с другом спаяли 1-битный сумматор на полевых транзисторах: три входа (A, B и перенос с предыдущего бита) и два выхода (сумма и перенос на следующий бит).

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

Старт был типичным. Я умел немного JS, приложений раньше не писал и просто «кодил как вижу». UI — это голый HTML + CSS, бизнес‑логика рядом, все вперемешку. Это тот самый код, за который сейчас очень стыдно, но каждый из нас проходил через это)
Удивительно, но с ужасным овно‑кодом через пару месяцев я получил работающий прототип. И самое главное — подходил под мои требования.
Схема умеет сохранять (set - верхний тумблер) и сбрасывать (reset - нижний тумблер) значение на лампе.
Слева подается сигнал через тумблеры, справа результат вычисления выводится на пины 7-сегментного индикатора. Да, читать эту схему - сущий кошмар, но главное, что она работает!

Данное устройство могло складывать и вычитать 4-битные числа, а также воспроизводить логическое И и ИЛИ. Слева располагаются по 4 тумблера для каждого входного числа, с справа сверху 2 тумблера для выбора команды.
Но релизным это не стало бы. Проект родился без опыта — и это было видно. Если всплывал баг, править приходилось в десятке мест. Файлы разрастались до 1500–2000 строк, структура плыла. Конец моего терпения настал при попытке добавить импорт / экспорт схем: я просто утонул в связях.
В итоге я заморозил первую версию. Зато четко понял, что хочу от следующей: границы слоев и слабая связность.
После первой версии мне захотелось, чтобы схема на экране оживала в реальном мире. Я вынес ядро на 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, паттерны. Это дало мне совершенно иное понимание о процессе разработки.
Что я кардинально поменял?
Data‑oriented архитектура.
Все сущности — это простые POJO (Plain Old JavaScript Objects), не знающие о логике. Все поведение вынесено в инструменты и сервисы, которые работают с этими объектами.
Ядро живет в веб‑воркере.
UI — тонкий слой: показывает состояние и шлет команды. Вся обработка данных — внутри воркера. Это убирает лаги в интерфейсе и готовит почву для «тяжелых» проектов.
Монорепо и пакеты с четкой ролью.
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 (шину событий) и дает возможность регистрации плагинов,
отвечает за жизненный цикл модулей.
Такой подход позволил сделать код модульным и изолированным: каждая часть отвечает только за свою задачу.
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 компактным, а внутри переиспользовать команды без дублирования.
Типобезопасный 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 */ });Плагинная система для расширения ядра, не трогая ядро.
Смысл здесь прост: не каждый захочет разбираться в чужих кишках. Плагин позволяет добавить или переопределить 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 я планирую писать на фреймворке SolidJS — он отлично подходит под идею реактивного интерфейса, где все обновляется по событиям из ядра. Часть кода клиента у меня уже написана, но она требует серьезного рефакторинга, чтобы привести ее в соответствие с текущей версией движка.
Параллельно в свободное время от программирования, я занимаюсь проектированием интерфейса рабочего пространства и дизайна элементов.

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







Таблица истинности — это отдельный элемент, который можно привязать к конкретной схеме. Во время симуляции она отображает текущее состояние всех входов и выходов, а при необходимости эти данные можно экспортировать для дальнейшего анализа.
В будущем я хочу развивать нодовую систему элементов, чтобы через соединения можно было передавать не только логические значения, но и более сложные объекты. Это откроет путь к новым типам элементов, которые смогут обрабатывать результаты симуляции: проверять корректность работы схем, находить ошибки, сравнивать ожидаемые и фактические значения, строить булевые функции и, наоборот, схемы по ним.
Если коротко, ядро доведено до состояния MVP под мои требования. Оно уже решает ключевые задачи и при этом остается расширяемым: создание кастомных схем, создание / удаление связей, элементов и вкладок, симуляция на N тиков.
Если я пойму, что вокруг моего проекта появляется интерес от аудитории, я обязательно найду и потрачу время, чтобы в ускоренном темпе выложить его в open‑source. Для этого нужно отполировать некоторые стыки и экспорты.
К диплому, я планирую довести ядро до полностью стабильного состояния и собрать минимальный рабочий интерфейс. Основная цель — показать архитектурную завершенность и работоспособность движка, а не визуальную часть.
После диплома хочу развивать проект дальше: превратить его в полноценную платформу для создания и обмена схемами, добавить возможность совместной работы, интеграция с Arduino и... размышлять можно бесконечно.
Спасибо за внимание!