Newer
Older
rpg / docs / engine / render.md

Рендер: Renderer, pixel-perfect, камера, IsoDepthLayer, частицы

Мир в юнитах, экран в пикселях

Внутренние координаты мира — мировые юниты (1 юнит = 1 тайл, float в плоскости изометрии). Экранные пиксели — это проекция юнитов: округление до целого пикселя происходит только на границе мир→экран (worldToScreen + округление во вью/Camera.apply), поэтому пиксель-арт остаётся резким. UI живёт в виртуальных пикселях (480×270) и никогда не переводится в юниты. Две «линейки»:

  • точка — анизотропная проекция: 1 юнит = tileW/2 px по X экрана и tileH/2 px по Y;
  • скаляр (дистанция, радиус, скорость, высота) — «линейка проекции»: px = units·tileW (32 px/юнит).

Правило отображения: «внутри вью — всегда px; вью позиционируется в мире через worldToScreen».

Renderer и pixel-perfect

Виртуальное разрешение (например 480×270) определяет экранную систему координат: UI и вью-позиции — в виртуальных пикселях, мир — в юнитах. Отрисовка двухслойная:

  • CSS-размер канваса — виртуальное разрешение, растянутое целым числом (computeScale), без зазоров и обрезки;
  • бэкинг-стор канваса — в пикселях устройства (scale × devicePixelRatio): UI и текст рендерятся 1:1 с экраном и остаются резкими, в том числе на HiDPI;
  • мир при этом остаётся пиксельным: текстуры, загруженные через AssetLoader, сэмплируются nearest (чёткие квадратные пиксели арта).

  • computeScale(vw, vh, winW, winH) — максимальный целый масштаб, при котором виртуальный экран влезает в окно;

  • renderer.resize(newScale) — смена масштаба; движок сам пересчитывает resolution устройства и растеризацию текста (см. PixelText); Engine делает это автоматически при ресайзе окна (autoResize: true);
  • канвасу выставлены image-rendering: pixelated, antialias: false, roundPixels: true.

Корневые контейнеры:

engine.renderer.worldRoot  // мир — сюда применяется камера
engine.renderer.lightRoot  // освещение — экранное пространство (см. «Освещение»)
engine.renderer.uiRoot     // UI поверх мира, камерой не двигается

Оверлей fade-переходов добавляется в uiRoot самим Engine — всегда верхний слой.

Camera

Позиция камеры (x, y) и bounds — в мировых юнитах (центр взгляда в плоскости изометрии). Клэмп через проекцию: экранный bbox границ не обязан лежать в положительных координатах — у ромба карты западный угол отрицательный, движок считает это сам:

const camera = engine.camera;
camera.follow(targetX, targetY);         // следить за точкой (юниты)
camera.bounds = map.worldBounds;         // { x: 0, y: 0, width, height } в юнитах

Позиция округляется до целого пикселя (apply), поэтому арт не дрожит при движении.

«Мёртвая зона» — современное поведение камеры: пока цель внутри окна, камера неподвижна; у границ окна цель «толкает» камеру (каждая ось независимо). Окно — экранное, остаётся в виртуальных пикселях (мировые прямоугольники проецируются в экранное окно строго 2:1, поэтому произвольное окно в юнитах невыразимо):

camera.deadZonePx = { width: 180, height: 120 }; // окно в виртуальных пикселях
camera.deadZonePx = null;                        // снова жёсткое центрирование
camera.snap(x, y);                               // мгновенно, минуя окно (спавн/респаун)

Тряска — для ударов, взрывов и урона. Движок сам тикает камеру (camera.update(dt) внутри фиксированного шага), игре достаточно запустить толчок:

camera.addShake(3, 0.25); // амплитуда 3 px, 0.25 сек
camera.shaking;           // true, пока трясёт
camera.clearShake();      // сбросить

Смещение детерминировано (внутри Shake — движковый Rng) и округляется до целых пикселей, так что пиксель-арт не размывается.

Для слоёв вне worldRoot (свет, экранные эффекты) есть camera.toScreen(wx, wy) — экранная позиция мировой точки с учётом камеры и тряски, та же математика, что у apply. Не вычисляйте проекцию через worldRoot.position — он обновляется при рендере и отстаёт на кадр; toScreen даёт позицию без лага.

Освещение

Два компонента, оба в renderer.lightRoot — экранное пространство между миром и UI:

  1. Ambient — fullscreen-спрайт с блендом multiply. Цвет и есть яркость: 0xffffff — свет не трогает сцену, тёмный — затемнение, цветной — тон. Фундамент смены времени дня: день = 0xffffff, закат = тёплый, ночь = тёмно-синий.
  2. Источники — аддитивные спрайты со ступенчатой пиксельной текстурой свечения (генерируется один раз makeGlowTexture; радиус нормируется на GLOW_BASE_PX). Тонкий Pixi-адаптер — Lighting, чистая математика — lightSim (lightFrame, flickerFactor, lerpAmbient, dimColor).
import { Lighting } from '@rpg/engine';

const lighting = new Lighting({ width: 480, height: 270, renderer: engine.renderer });
engine.renderer.lightRoot.addChild(lighting);

lighting.setAmbient(0x54586a, 1.5);       // тёмный интерьер с плавным переходом 1.5 c
lighting.upsertLight({ id: 'hearth', x: 100, y: 40, color: 0xf2b45a,
                       intensity: 1, radius: 64, flicker: 0.3, seed: 0.2 });
lighting.setLightPos('hearth', 110, 45);  // runtime: move/color/enable/remove
lighting.removeLight('hearth');

lighting.update(dt);   // каждый тик сцены: время, лерп ambient, мерцание
lighting.frames();     // видимые кадры источников (для снапшота агента)

Позиции источников — экранные px: сцена пересчитывает их каждый тик через camera.toScreen(worldToScreen-координаты точки) (движок не знает об изометрии). Пул спрайтов фиксирован (maxLights, по умолчанию 16); лишние upsertLight игнорируются. Плавный лерп ambient (setAmbient(color, fadeSec)) — база для day/night: день/ночь — кейфреймы поверх этого API.

Для непрерывного цикла суток в lightSim есть чистая кривая: dayNightFactor(tHours, spec?) — фактор ночи 0..1 по часам (mod 24), линейный на рассвете/закате; границы задаёт DayNightSpec (DEFAULT_DAY_NIGHT: рассвет 06–08, закат 18–20). Игра смешивает дневной и ночной ambient области: lerpAmbient(base, night, factor) и ставит цель через setAmbient(target, 0) каждый тик — переход сам растянут кривой на десятки секунд, отдельный фейд не нужен.

Импульсы, виньетка, тёмные пятна

Поверх базового API у Lighting есть три короткоживущих/локальных эффекта — все на тех же ступенчатых текстурах, без шейдеров:

  • pulseLight({ x, y, color, intensity, radius, spec }) — вспышка в точке: занимает спрайт общего пула источников, интенсивность модулируется огибающей PulseSpec { attack, hold?, decay } (чистая математика — pulseEnvelope в lightSim), по концу огибающей источник снимается сам. В кадрах frames() импульсы видны с id pulse#N.
  • pulseAmbient({ color, peak, spec }) — вспышка на весь экран (аддитивный fullscreen-спрайт, alpha = peak × огибающая); один слот — новая вспышка побеждает старую.
  • setVignette(intensity 0..1, fadeSec) — затемнение краёв экрана (multiply-текстура makeVignetteTexture, ступенчатые кольца от белого центра): низкий hp, опасная зона. Лерп по образцу ambient.
  • upsertDarkSpot({ id, x, y, radius, alpha }) / removeDarkSpot — локальная тень (multiply-спрайт с той же glow-текстурой, tint тёмный): ауры опасных зон. Отдельный пул maxDarkSpots (по умолчанию 16); пятна рисуются под источниками — свет пробивает локальную тень.
lighting.pulseLight({ x: 120, y: 60, color: 0xf2b45a, intensity: 0.5,
                      spec: { attack: 0.02, decay: 0.2 } });   // удар
lighting.pulseAmbient({ color: 0xb0453f, peak: 0.18,
                        spec: { attack: 0.02, decay: 0.35 } }); // урон герою
lighting.setVignette(0.35, 0.5);   // край экрана темнеет за полсекунды
lighting.upsertDarkSpot({ id: 'hazard@3,4', x: 200, y: 100, radius: 51, alpha: 0.22 });

IsoDepthLayer

Сортировка глубины для сущностей на изометрической карте: глубина = tx + ty (чем юго-восточнее, тем «ближе»). Герой, NPC, деревья добавляются сюда, а не в слой карты:

import { IsoDepthLayer } from '@rpg/engine';

const actors = new IsoDepthLayer();       // sortableChildren = true внутри
engine.renderer.worldRoot.addChild(actors);

actors.add(heroView, tileX, tileY);       // добавить и выставить глубину
actors.setDepth(heroView, tileX, tileY);  // обновить при движении (по смене тайла)

actors.addRect(houseView, x, y, w, h);    // крупный объект: глубина по footprint

Слой карты (IsometricTileMap.view) и слой сущностей — соседи: карта рисует высокие объекты со своей внутренней сортировкой, сущности сортируются отдельно. Для корректного перекрытия герой/дерево должны быть в одном слое с деревьями — либо используйте tall-объекты карты и держите сущности поверх (см. recipes). Крупные объекты (props, footprint w×h) сортируются через addRect — глубина по прямоугольнику, а не по точке якоря (см. maps.md). Эффекты (искры, кольца) — через addFx(view, tx, ty, bias = 0.5): глубина тайла с надбавкой, чтобы искры удара были поверх актёра на том же тайле.

Particles

Эмиттер частиц для атмосферы (пепел, мотыльки, пыль, искры) и боевых эффектов (удары, взрывы). Частицы — подкрашенные квадратики 1–4 виртуальных пикселя или текстуры; всё детерминировано по seed. Тикается через engine.fx либо вручную из сцены.

import { ParticleEmitter } from '@rpg/engine';

// Атмосферные мотыльки: тёплый свет, дрейф, нарастание и затухание альфы
const moths = new ParticleEmitter({
    colors: [0xf0d878, 0xd8b050],       // случайный цвет из списка при спавне
    rate: 3,                            // частиц в секунду
    lifetime: [2, 5],                   // сек
    velocity: { x: [-6, 6], y: [-9, -3] },
    acceleration: { y: 1.5 },           // «тяжёлый» полёт мотылька
    wobble: 8,                          // синус-дрейф по X (фаза — у каждой своя)
    fadeIn: 0.5,                        // сек нарастания альфы
    size: 2,
    blend: 'add',                       // светящиеся: огонь, искры, мотыльки
    spawnArea: { width: 480, height: 200 },
    seed: 42
});
moths.position.set(240, 135);
engine.renderer.worldRoot.addChild(moths);
engine.fx.add(moths);                   // тик и зачистка (по destroyed) — сами

Основные опции (все, кроме rate/lifetime, опциональны; в burst-опциях с radialSpeed velocity не нужен):

Опция Что делает
color / colors / colorOverLife цвет, случайный из списка, лерп по жизни
texture, scale текстурная частица вместо квадрата (anchor 0.5)
size размер квадрата: число или диапазон
rotation / spin начальный угол и скорость вращения (рад)
scaleOverLife [a, b] или 'shrink'/'grow'
fadeIn / fadeOut нарастание (сек); затухание: true — вся жизнь, число — хвост в сек, false — без
drag торможение, 1/сек (искры, пух)
wobble амплитуда синус-дрейфа по X, px/сек
max лимит живых частиц (пул спрайтов), по умолчанию 256
blend 'add' — светящиеся частицы

Для боевых эффектов есть разовые всплески:

// burst: мгновенно выпустить частицы из существующего эмиттера.
// opts — разовые переопределения; базовые опции НЕ мутируются.
hitFx.burst(12, { color: 0x999988, radialSpeed: [40, 90], lifetime: [0.2, 0.5] });

// oneShot: самостоятельный эмиттер-взрыв; уничтожает себя, когда все умерли
// (сам выпадает из engine.fx), onFinish — для цепочек эффектов.
const boom = ParticleEmitter.oneShot(16, {
    color: 0x777766, lifetime: [0.3, 0.6],
    radialSpeed: [50, 120], size: 2, drag: 3, spin: [-4, 4],
    scaleOverLife: 'shrink', seed: 7
});
boom.position.set(x, y);
worldRoot.addChild(boom);
engine.fx.add(boom);                    // тикается сам, ручной update не нужен

Depth: чтобы искры удара корректно перекрывались актёрами, добавляйте эмиттер в IsoDepthLayer через addFx(view, tx, ty) (надбавка +0.5 — поверх актёра на том же тайле); у движущихся эффектов обновляйте setDepth там же, где глубину вью.

Интеграция частицы — чистый модуль render/particleSim, тикается и без Pixi (например, в тестах боя):

// breaking (v2): stepParticle возвращает видимые кривые, а не мутирует альфу
const visual = stepParticle(state, dt, { drag: 2, scaleOverLife: 'shrink' });
// visual: { alpha, scale, rotation, tint }

sampleSpawn/sampleBurst — спавн по опциям эмиттера и радиальный «взрыв»; mergeEmitterOptions(base, over) — чистое слияние (используется внутри burst). Частицы — display-layer: все внутренние величины эмиттера — экранные px относительно его origin; в мире эмиттер позиционируется через worldToScreen.

Визуальные эффекты: render/fx/

Слой one-shot эффектов и предзаготовленные пресеты. FxLayer — контейнер, реализующий Updatable: добавляется в сцену один раз (engine.fx.add(layer)), сам тикает всё, что спавнит, и сам вычищает закончившиеся. Не нужно помнить про engine.fx.add на каждый эффект — типичный источник багов «эффект висит статичной графикой».

import { FxLayer, sparkPreset } from '@rpg/engine';

const fx = new FxLayer();
worldRoot.addChild(fx);          // или uiRoot для тостов
engine.fx.add(fx);               // один раз на сцену; в exit() — fx.destroy()

fx.ring({ color: 0xd99a32, from: 8, to: 96, duration: 0.4 }, at);   // кольцо/волна
fx.floatText('+2', at, { rise: 10 });                               // всплывающий текст
fx.fade(view, { duration: 0.5 });                                   // растворение + destroy
fx.pop(view, { fromScale: 0.5 });                                   // появление с овершутом
fx.flash(sprite, 0xf2b45a, 0.15);                                   // хит-флэш (1 на спрайт)
fx.burst(10, at, sparkPreset(0xf2b45a, { seed }));                  // разовые частицы
fx.trail(projectile, { color: 0x86868f, rate: 30 });                // шлейф за вьюхой

Правила:

  • Позиции — локальные px контейнера, куда добавлен слой. Слой жанронезависим: изометрию знает сцена (мировые эффекты — через worldToScreen/camera.toScreen, как у Lighting). Для depth-сортировки передайте { parent: depthLayer } и сами вызовите addFx(view, tx, ty).
  • Пресет владеет своей вьюхой: создаёт, тикает и уничтожает сам; вызывающий получает хэндл с cancel(). Повторный flash() по тому же спрайту перезапускает вспышку (не создаёт новую).
  • clear() гасит всё активное (смена тоста, exit сцены). Если нужны независимые группы эффектов — заведите по слою на группу (мир / UI).
  • Зацикленные телеграфы зонring с repeat: Infinity (опц. shape: 'diamond', fill): гасятся только cancel() или clear() — сцена обязана погасить, когда зона перестала быть актуальной.
  • Чистая математика сэмплов — fxSim.ts (sampleRing, sampleFloat, readSeconds, sampleFade, samplePop), тестируется headless; адаптеры только применяют сэмплы. Фабрики частиц — sparkPreset/smokePreset/emberPreset/dustPreset (поверх mergeEmitterOptions).
  • Тик слоя идёт внутри engine.fxдо scenes.update: позиция, снятая из мира, отстаёт на 1 тик (тот же лаг у SpriteMotion/Lighting).

Порядок отрисовки

Стек сцен рисуется снизу вверх; внутри сцены порядок задают дочерние контейнеры (worldRoot: карта → сущности → эффекты; lightRoot: ambient → источники света; uiRoot: дымка/HUD → диалоги → fade-оверлей — всё это поверх освещения). SceneManager.render() вызывает render() всех сцен в стеке (не только верхней).