Как устроен ГИГАХРУЩ: клеточный мир, WebGL-рейкастер и A-Life без движка

Привет, XGM! Вы любите кастомные движки и нестандартные технические решения, поэтому этот пост — для вас. ГИГАХРУЩ — это браузерный survival horror / ARPG на TypeScript и WebGL. В этой статье мы подробно разберем нашу архитектуру: от тороидального клеточного мира и typed arrays до написания своего рейкастера, оптимизации A-Life и системы Самосбора как мутации памяти.
Разберем, как ГИГАХРУЩ устроен под бетоном: один активный клеточный этаж, typed arrays, плоские сущности, WebGL raycasting, A-Life, самосбор как мутация мира, сохранение, ограничения и MESH PASS как render-only объем поверх клеточной симуляции.
*Общий контракт: генераторы строят World, системы меняют состояние, рендер только читает.*

Ограничение, из которого выросла архитектура

ГИГАХРУЩ - браузерная survival-horror / ARPG игра на TypeScript/Vite. Цель - один запускаемый браузерный билд без runtime-фреймворков, без импортированного ассетного пайплайна и без внешнего движка.
Это не религиозная позиция "движки плохие". Unity, Godot, Phaser, Three.js и готовые ECS решают нормальные задачи. Но в этом проекте базовая ставка другая:
  • мир должен быть процедурным и перестраиваемым;
  • рендер должен читать игровое состояние, а не владеть им;
  • контент должен добавляться модулями, не залезая в главный цикл;
  • NPC и монстры должны жить на общей поверхности, а не появляться как декорации вокруг камеры;
  • локальный single-file build должен работать даже без сети и облачного контура;
  • любые дорогие системы должны иметь понятный cap, cache, cadence или dirty flag.
Из этого получилось не "сделали свой Unity на коленке", а более узкая вещь: кастомная игра-движок под конкретную форму мира.

Пять слоев вместо большой сцены

Внутри проект держится на очень скучном контракте:
core     - примитивные типы, World, константы
data     - определения предметов, оружия, фракций, квестов, монстров
gen      - генерация этажей, комнат, POI, начального размещения
systems  - AI, бой, A-Life, самосбор, экономика, сохранение, интеракции
render   - WebGL/canvas, HUD, карта, спрайты, текстуры
Самое важное здесь - направление зависимости. render не решает геймплей. data не мутирует мир. gen строит начальное состояние. systems меняют состояние в runtime. main.ts не должен знать, что появился особый монстр, особый этаж или особый терминал.
Это звучит банально, но для проекта, который постоянно просит "добавь еще один странный этаж", это вопрос выживания. Каждый новый контентный пакет хочет стать исключением. Если дать ему право на отдельный tick, отдельный input path, отдельный render branch и отдельную save shape, игра быстро превратится в свалку.
Поэтому новая странность должна отвечать на простой вопрос: "Как она ложится в существующий World, регистр, событие, интеракцию или генератор?"

Мир как несколько полей над одной решеткой

Один активный этаж ГИГАХРУЩА - это клеточная поверхность 1024x1024. Координаты заворачиваются по обеим осям, то есть локально это обычная grid-карта, но глобально у нее нет края. Технически это тороидальная решетка.
В коде это очень приземленная вещь:
wrap(v) = ((v % W) + W) % W
idx(x, y) = wrap(y) * W + wrap(x)
Одна клетка имеет один линейный индекс. Дальше над этим индексом лежат разные поля:
cells[i]          // стена, пол, дверь, вода, лифт
roomMap[i]        // id комнаты или -1
wallTex[i]        // текстура стены
floorTex[i]       // текстура пола
features[i]       // лампа, кровать, стол, экран, туалет
light[i]          // свет
fog[i]            // туман
zoneMap[i]        // макрозона
factionControl[i] // контроль фракции над клеткой
Плотные данные живут в typed arrays. Редкие данные живут в обычных структурах:
rooms: Room[]
doors: Map<cellIndex, Door>
containers: WorldContainer[]
surfaceMap: Map<cellIndex, Uint8Array>
entities: Entity[]
Это и есть ключевая разница между "уровнем как сценой" и "уровнем как состоянием". Стена - не объект с transform в сцене. Стена - значение в cells
`
ОЖИДАНИЕ РЕКЛАМЫ...