Аспекты
Прикрепляемые возможности узла — композиция с лаконичностью методов. Всё, что узел умеет сверх
своего трансформа (анимация, коллайдеры, физика, твоя игровая логика), прикрепляется как аспект, а
не раздувает класс узла. Встроенные SpriteAnimation, Shape2D, Physics2D — это аспекты; твои
поведения расширяют Aspect тем же способом.
Обзор
class Health extends Aspect<'health', Sprite> {
hp = 100
hurt(n: number) {
this.hp -= n
if (this.hp <= 0) this.node.destroy()
}
}
const hero = new Sprite({ texture })
.aspect(SpriteAnimation, { size: [32, 48], fps: 10, clips: { walk: [1, 2, 3] } })
.aspect(Health, { hp: 80 }) // прикрепить + настроить; чейнится — возвращает узел
hero.anim.play('walk') // каждый аспект добавляет именованный аксессор
hero.health.hurt(10)Прикрепление, доступ, открепление
node.aspect(Class, opts?): node & { name: instance } // прикрепить + настроить (или перенастроить)
node.get(Class): instance | undefined // безопасный доступ — undefined, если не прикреплён
node.has(Class): boolean // проверка наличия И type guard
node.removeAspect(Class): node // открепить (запускает onDetach); чейнитсяaspect()возвращает узел, типизированный как уже имеющий этот аспект, поэтому именованный аксессор работает без guard'а сразу после цепочки.- Именованный аксессор (
hero.anim,hero.health, …) берётся из класса аспекта: встроенные объявляют имя черезstatic aspect; для твоих аспектов сборка извлекает имя из первого дженерик-параметра (Aspect<'health', …>→node.health). has()— это type guard: внутриif (node.has(Physics2D))типnode.physicsсчитается присутствующим.
вызов .aspect(Class, opts) на уже прикреплённом аспекте не прикрепляет заново — он просто
делает Object.assign опций на существующий экземпляр. onAttach выполняется один раз, только при
первом прикреплении.
Свой аспект
class Follow extends Aspect<'follow', Sprite> { // <имя аксессора, вид целевого узла>
target?: Node2D
speed = 4 // поля класса = настраиваемые значения по умолчанию
onAttach() { /* node установлен; opts уже присвоены */ }
onDetach() { /* очистка: таймеры, слушатели */ }
update(dt: number) { // dt в секундах
if (this.target) this.node.position = this.node.position.lerp(this.target.position, this.speed * dt)
}
}
sprite.aspect(Follow, { target: hero, speed: 6 })this.node— узел, к которому прикреплён аспект, типизированный по второму дженерик-параметру. Прикрепление к узлу неверного вида — ошибка компиляции.- Аспекты создаёт движок через
node.aspect(), никогда неnew— не пиши конструктор. Инициализируй вonAttach(к этому моменту узел и опции уже установлены); настраиваемые параметры объявляй полями класса со значениями по умолчанию.
Покадровый update(dt)
Аспект, определяющий update(dt), тикает каждый кадр, пока прикреплён (dt = секунды с прошлого
кадра). Обновления идут в одной из двух фаз:
- LATE (по умолчанию) — после шага физики и синхронизации трансформов, прямо перед отрисовкой
кадра. Чтение
node.worldPositionдаёт финальную отрисованную позицию, поэтому камеры и «преследователи» встают точно, без отставания на кадр. - EARLY (
updateBeforePhysics = true) — до шага физики, поэтому записи скорости, сил или кинематических трансформов учитываются шагом того же кадра (нулевая задержка ввода).
class Steer extends Aspect<'steer', Sprite> {
updateBeforePhysics = true // этот аспект ПИТАЕТ симуляцию — выполнять до шага
order = -10 // порядок тика внутри фазы: по возрастанию; по умолчанию 0, при равенстве — порядок прикрепления
update(dt: number) { /* пиши this.node.physics.velocity здесь */ }
}Правило: аспекты, которые пишут в симуляцию, идут в EARLY; аспекты, которые читают результаты (камеры, преследователи, синхронизация UI), остаются в фазе LATE по умолчанию.
updateBeforePhysics и order читаются один раз, при прикреплении — задавай их полями класса
(или в опциях прикрепления), не позже.
updateWhenVisible объявлен, чтобы аспекты могли подписаться на отсечение по видимости, но сейчас
это заглушка — каждый зарегистрированный аспект тикает независимо от того, на экране он или нет.
With<N, A> — типизация переменной с аспектами
let boss: With<Sprite, Health | Physics2D> // Sprite, про который ИЗВЕСТНО, что у него оба аспекта
boss.health.hurt(10) // guard не нуженОбъединяй аспекты через union (не кортеж) — так читается естественно и повторяет рантайм-guard
node.has(Health). Используй для полей и параметров функций, принимающих узлы, собранные в другом
месте.
Смотрите также
- Соглашения — стиль цепочек, в который вписываются аспекты.
- События — узлы также являются эмиттерами (
addEventListener). - Sprite — типичный хост для аспектов.