Архитектурный бунт: пишем Client-Side Data-Driven квест без фреймворков и воюем с ИИ за SOLID

Текстовый ретро-экспрессионизм: как разработать масштабируемый движок, оживляя книги-игры Р.Л. Стайна на ванильном TypeScript.

Дисклеймер: Про Хабр и линтер на стероидах

Эта статья должна была выйти на Хабре. Но местная модерация Песочницы устроила параноидальный рейд: вместо возврата текста в черновики для правок, они полностью снесли мой двухнедельный труд под предлогом «использования ИИ для литредактирования». Доказывать очевидное вахтёрам я не стала, хладнокровно потребовала удалить аккаунт и ушла на vc.ru.

Официальный отказ модерации с шутками про НЛО и просьбой «не терроризировать поддержку».
Официальный отказ модерации с шутками про НЛО и просьбой «не терроризировать поддержку».

Реакция поддержки на мое требование снести профиль оказалась бесценной. Ребята мгновенно осознали масштаб факапа, запутались в собственных CRM-ссылках и судорожно побежали спасать мой текст на Google Диск.

Стадия принятия. Извинения, битые ссылки и выдача бэкапа на блюдечке.
Стадия принятия. Извинения, битые ссылки и выдача бэкапа на блюдечке.

Заявляю прямо: Фактура, архитектура, код и коммиты в этом проекте — полностью мои. Но я ценю своё время, поэтому причёсывать текст, структурировать мысли и расставлять запятые мне помогал ИИ-ассистент. Если у кого-то от этого факта срабатывает триггер — это ваши проблемы. А мы переходим к мясу.

Проблема кривых EPUB и Р.Л. Стайн

В детстве многие зачитывались интерактивными книгами-квестами Р. Л. Стайна («Ужастики-2» от издательства «Росмэн», 2002–2006 гг.). Логика там была классической: прочитал блок текста — сделай выбор. «Если откроешь сундук — перейди на страницу 42. Если побежишь к выходу — на страницу 11».

Решив перечитать серию, я столкнулась с техническим адом. Бумажные оригиналы — дефицит, а электронные версии свёрстаны настолько криво, что постоянные прыжки по внутренним ссылкам в читалках превращают процесс в пытку.

Идея напросилась сама: если нет удобного интерфейса, нужно написать его под себя. Изначально планировалась простая веб-страничка с переходами. Однако в процессе проектирования данные потребовали строгой структуры, абстракции — чистоты, а проект за две недели плотной автономной работы эволюционировал в полноценный Client-Side Data-Driven SPA-движок на чистом Native TypeScript. Без единого фреймворка, внешних зависимостей и легаси-костылей.

Архитектурные ограничения и паттерны

Проект проектировался с прицелом на максимальную автономность (Client-Side) и управляемость данными (Data-Driven). Чтобы не превратить кодовую базу в кашу, я заложила три архитектурных правила:

  • Чистый MVP (Model-View-Presenter): Полная изоляция слоёв. View ничего не знает о данных, Model ничего не знает об интерфейсе. Presenter управляет логикой вслепую, коммуницируя со слоями строго через абстрактные интерфейсы. Это прямая реализация принципов SOLID, в частности DIP (Dependency Inversion Principle).
  • Data-driven подход & In-Memory Cache: Ноль хардкода. Весь контент квестов вынесен в JSON-конфиги. Глобальный config.json содержит лишь реестр доступных книг, а движок выкачивает структуру конкретного произведения один раз при старте. Переключение страниц и книг происходит мгновенно, в оперативной памяти, без повторных сетевых запросов и дебильных спиннеров.
  • Паттерн Repository для персистентности: Изначально планировался простой хелпер для работы с памятью, но логика быстро потребовала выделения полноценного слоя репозитория (BookStorage). Он инкапсулирует работу с LocalStorage, суффиксами сохранений и состоянием инвентаря пользователя (проверка наличия предметов для открытия заблокированных страниц). Presenter не имеет понятия, куда и как пишутся данные.

Вот так выглядит корневой JSON-реестр, управляющий каталогом доступных интерактивных миров:

[ "all-day-nightmare", "deep-in-the-jungle-of-doom", "diary-of-a-mad-mummy", "elevator-to-nowhere", "hocus-pocus-horror", "human-squeezers", "into-the-twister-of-terror", "night-in-werewolf-woods", "shop-till-you-drop-dead", "trapped-in-the-circus-of-fear", "under-the-magicians-spell", "you-come-back-from-the-internet", "youre-plant-food" ]

Вот реализация класса BookStorage, изолирующего персистентное состояние и инвентарь игрока:

import { getStorageItem } from "./utils"; import { IBookStorage } from "./types"; export class BookStorage implements IBookStorage { private readonly inventorySuffix = '_inventory'; private readonly endingsSuffix = '_endings'; initBookStorage (bookID: string) { const bookInventory = `${bookID}${this.inventorySuffix}`; const bookEndings = `${bookID}${this.endingsSuffix}`; if (getStorageItem(bookInventory).length === 0) { localStorage.setItem(bookInventory, JSON.stringify([])); } if (getStorageItem(bookEndings).length === 0) { localStorage.setItem(bookEndings, JSON.stringify([])); } } addItem (bookId: string, itemId: string) { const bookInventory = `${bookId}${this.inventorySuffix}`; let inventory = getStorageItem(bookInventory); if (!inventory.includes(itemId)) { inventory.push(itemId); localStorage.setItem(bookInventory, JSON.stringify(inventory)); } } addEnding (bookId: string, endingId: number) { const bookEndings = `${bookId}${this.endingsSuffix}`; let endings = getStorageItem(bookEndings); if (!endings.includes(endingId)) { endings.push(endingId); localStorage.setItem(bookEndings, JSON.stringify(endings)); } } getInventory (bookId: string): string[] { const bookInventory = `${bookId}${this.inventorySuffix}`; return getStorageItem(bookInventory); } getEndings (bookId: string): number[] { const bookEndings = `${bookId}${this.endingsSuffix}`; return getStorageItem(bookEndings); } }

Принцип KISS и разделение контента (Демо vs Локал)

Разделение прав на контент стало главным триггером для оптимизации TCO проекта. В публичный деплой на Vercel улетела исключительно легальная ознакомительная демо-версия. Полный массив книг со всеми ветвлениями крутится только локально на моей машине.

Публичная модалка срабатывает как триггер окончания демо-сценария. Сброс игрового состояния в демо-режиме решён строго по принципу KISS — банальной перезагрузкой страницы при закрытии этого модального окна. Никакого оверхеда, сложных стейт-менеджеров и тонны кода для очистки памяти. Система просто возвращается в исходное состояние.

Стерильный ретро-канон без мобильной разметки

Проект принципиально проектировался исключительно под Desktop. Никакого мобильного адаптива здесь нет и не планировалось. Продукт должен ощущаться как старая печатная книга.

Для этого были приняты два жестких ограничения:

  • Шрифт: Базовый кастомный шрифт Venom Mincho физически не имеет начертания bold. Весь интерфейс завязан на фиксированную толщину символов, что отсекает любой современный UI-дизайн.
  • Графика: Для иконок собран производительный SVG-спрайт через тег <use>. Это исключает лишнюю нагрузку на браузер и рендеринг векторов через JS на лету.

Битва с ИИ за SOLID и ошибка 403

Самым изматывыющим процессом в эти две недели стало управление ИИ-ассистентом. Я использовала нейросеть для черновой работы и генерации бойлерплейта под TypeScript, но это была регулярная война за архитектуру.

Языковые модели по своей природе ленивы. ИИ постоянно пытался скатиться в процедурную кашу, навязать спагетти-код, галлюцинировал методами и упорно игнорировал мою готовую утилиту `getStorageItem`, пытаясь связать модули напрямую и нарушить инкапсуляцию.

Мне приходилось регулярно бить алгоритм по виртуальным рукам жесткими промптами:

  • «Нет, мы не импортируем модель во View. Презентер управляет этим вслепую».
  • «Нет, этот метод должен быть скрыт за контрактом интерфейса, а не торчать наружу».
  • «Включай DIP, мы пишем расширяемую систему, а не формочку-однодневку».

В ходе этой дискуссии нейросеть под конец, кажется, сломалась от моих требований к SOLID. В один момент, когда мы проектировали сложную логику проверки предметов в инвентаре, ИИ просто обрушил контекст чата и выдал ошибку 403 Forbidden. Но архитектурное эго победило — структуру приложения я отстояла, код остался чистым, а каплинг модулей — слабым.

Выводы по TCO и техдолгу

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

Проект успешно задеплоен на Vercel и автономен.

Поиграть и оценить ретро-атмосферу (демо-версия):

Посмотреть на чистый TS-код и SOLID-архитектуру на GitHub:

Буду рада циничному инженерному фидбеку и разбору архитектуры в комментариях!