# Зоны-триггеры — `$.triggers` и тег `<trigger>`

Подсистема `triggers.js` делает `<trigger>` тем, чем он и был задокументирован:
**зоной, которая шлёт `enter` / `leave`**, когда в неё входит и выходит узел.
Заодно она закрывает второй реальный баг — `.overlaps(sel, cb)`, который
подписывался на событие `'tick'`, а его никто не рассылал.

```js
$.ready(() => {
    // Зона-дверь: вошёл игрок — открываем, вышел — закрываем.
    $('<trigger>', { id: 'door' }).at(300, 300).size(80, 80).appendTo($.world);

    $('#door').on('enter', (e) => {
        if (e.data.other.get(0).tag === 'player') $.sound.play('door-open');
    });
    $('#door').on('leave', () => $.sound.play('door-close'));

    // .overlaps(sel, cb) теперь действительно вызывается каждый кадр.
    $('#hero').overlaps('.lava', (hit) => hit && hit.damage(10 * $.time.delta()));
});
```

---

## 1. Что именно починено

| Было | Стало |
|---|---|
| `<trigger>` — обычный узел, `enter`/`leave` не приходили | любая зона шлёт `enter`/`leave` при пересечении |
| `.overlaps(sel, cb)` подписывался на `'tick'` и не срабатывал | подписка идёт в реестр `watchOverlap`, `cb` зовётся каждый кадр |
| `.overlaps(sel)` (вариант `bool`) работал | работает без изменений |

Событие `'tick'` в API не рассылается нигде: трогать подписку было нечем,
поэтому `.overlaps(sel, cb)` молчал. Теперь интегратор подключает в `api.js`
экспортированный `watchOverlap`, и колбэк получает найденную цель или `null`.

---

## 2. Зона: кто это и что она ловит

Зоной считается узел, у которого выполнено хотя бы одно условие:

| Признак | Пример |
|---|---|
| тег `trigger` | `$('<trigger>').at(300, 300)` |
| класс `trigger` | `$('<area>', { class: 'trigger' })` |
| `attrs.trigger === true` | `$('<rect>', { trigger: true })` |

Зона следит за **пересечением своего прямоугольника** с прямоугольниками
других узлов (та же проверка, что `boundsOverlap` в ядре; касание краем
входом не считается).

### Кого зона считает вошедшим

| Ситуация | Цели зоны |
|---|---|
| `attrs.detect` не задан | узлы с физическим телом (`node.body >= 0`) |
| `attrs.detect` задан | только узлы, совпавшие с ним — **даже без тела** |

`attrs.detect` принимает:

| Тип | Пример | Смысл |
|---|---|---|
| селектор | `{ detect: '.enemy' }` | по классу, тегу, id, псевдоклассу |
| тег | `{ detect: 'player' }` | то же, короче |
| массив | `{ detect: ['enemy', '.boss'] }` | любое из совпадений |
| функция | `{ detect: (n) => n.attrs.team === 2 }` | произвольное условие |
| обёртка/узел | `{ detect: $('#hero') }` | ровно этот узел |

Из целей **всегда исключены**: сама зона, другие зоны (иначе триггеры
срабатывали бы друг на друга) и узлы интерфейса (`attrs.ui`).

---

## 3. Семантика `enter` / `leave`

* при первом кадре пересечения зона шлёт **одно** событие `enter`;
* пока пересечение не прервалось, `enter` **не повторяется** — сколько бы
  кадров цель ни стояла в зоне;
* когда цель вышла (или пересечение исчезло), приходит **одно** `leave`;
* повторный вход — снова `enter`; выход и вход независимы.

| Событие | Когда | `event.data` |
|---|---|---|
| `enter` | цель впервые пересекла зону | `{ other, self }` — обёртки цели и зоны |
| `leave` | цель перестала пересекать зону | `{ other, self }` |

```js
$('#door').on('enter', (e) => {
    const who = e.data.other;        // обёртка вошедшего узла
    const zone = e.data.self;        // обёртка самой зоны (== e.self)
    $.log(`вошёл ${who.get(0).tag} в зону ${zone.get(0).id}`);
});
```

События рассылаются и глобально через `Node.emit`, поэтому работают
`$.on('entity:enter', …)` и любые общие подписки.

### Исчезнувшая цель — это выход

Если цель удалили (`.remove()` / `destroy()`), пока она была в зоне, зона
**всё равно шлёт `leave`** — иначе обработчики навсегда остались бы в
состоянии «внутри». В `e.data.other` при этом лежит уже удалённый узел:
`e.data.other.get(0).removed === true`.

### Невидимая зона не работает

`visible: false` выключает зону: новых `enter` нет, а прошлые пересечения
закрываются событиями `leave`. Чтобы зона была **невидимой, но рабочей**,
задайте `color: '#00ff0000'` или `alpha: 0` (отрисовка пропускает такие
узлы, а логика продолжает работать).

---

## 4. Публичное API

### `$.triggers`

| Метод | Назначение |
|---|---|
| `$.triggers.zone(opts)` | создать зону, вернуть обёртку |
| `$.triggers.list()` | все зоны мира — массив обёрток |
| `$.triggers.clear()` | удалить **все** зоны мира |
| `$.triggers.inside(zone, node)` | пересекается ли узел с зоной сейчас (bool) |
| `$.triggers.count(zone)` | сколько живых целей сейчас в зоне |

`zone`, `node` в `inside`/`count` принимают узел, обёртку или селектор
(`'#door'`, `'.lava'`). `count` учитывает `attrs.detect` зоны, поэтому для
detect-зоны считаются только её цели.

### `$.triggers.zone(opts)`

| Поле | Тип | По умолчанию | Смысл |
|---|---|---|---|
| `x`, `y` | number | `0` | центр зоны, как у остальных узлов |
| `w`, `h` | number | `100` | размеры |
| `detect` | селектор/массив/функция | — | кого ловить; без него — тела |
| `id`, `class` | string | — | id/класс узла-зоны |
| `tag` | string | `'trigger'` | тег узла (`'area'` — если нужен свой вид) |
| `color`, `alpha` | цвет/число | цвет из тега | вид зоны; `alpha: 0` — невидимая рабочая |
| `visible` | bool | `true` | `false` — зона выключена |
| `parent` | узел/обёртка | `$.world` | куда добавить |
| `onEnter` | `(other, self) => {}` | — | обёртки цели и зоны |
| `onLeave` | `(other, self) => {}` | — | то же при выходе |

```js
$.triggers.zone({
    id: 'shop',
    x: 200, y: 500, w: 80, h: 80,
    detect: '.customer',
    color: '#ffcc0033',
    onEnter: (who) => who.addClass('at-shop'),
    onLeave: (who) => who.removeClass('at-shop'),
});
```

### Чистые функции (экспортируются, проверяются qjs)

| Функция | Что делает |
|---|---|
| `zoneContains(zone, node)` | `true`, если прямоугольники пересекаются (учитывает `hitbox`, `scale_x/scale_y`) |
| `diffOverlaps(prevSet, nextSet)` | `{ entered, left }` — кто вошёл и кто вышел |

`zoneContains` понимает и узел, и простой дескриптор `{ x, y, w, h }`,
поэтому её удобно проверять без движка:

```js
zoneContains({ x: 0, y: 0, w: 100, h: 100 }, { x: 45, y: 0, w: 10, h: 10 }); // true
zoneContains({ x: 0, y: 0, w: 100, h: 100 }, { x: 100, y: 0, w: 10, h: 10 }); // false (касание)
diffOverlaps(new Set([a, b]), new Set([b, c]));  // { entered: [c], left: [a] }
```

### `watchOverlap(node, others, cb)`

Реестр покадровых наблюдателей пересечения. Возвращает функцию отписки.

```js
const off = watchOverlap($('#hero').get(0), query('.lava'), (hit, self) => {
    if (hit) hit.damage(1);
});
// позже: off();
```

Поведение `cb`:

| Аргумент | Значение |
|---|---|
| `hit` | обёртка первой живой цели, пересекающейся с `node`, или `null` |
| `self` | обёртка самого наблюдающего узла |

`cb` вызывается **каждый кадр** (а не только на изменение). Удалённые
(«потерянные») цели пропускаются и дают `null` — исключения не летят; если
удалён сам наблюдающий узел, наблюдатель снимается автоматически. Ошибка
внутри `cb` ловится и пишется в журнал, кадр не падает.

Именно сюда подключается `.overlaps(sel, cb)`: `sel` разбирается в массив
узлов через `query(sel)` один раз при подписке.

---

## 5. Примеры

### Дверь открывается только для игрока

```js
$('<trigger>', { id: 'door', detect: 'player' }).at(300, 300).size(80, 80).appendTo($.world);

$('#door').on('enter', () => $('#door').attr('open', true));
$('#door').on('leave', () => $('#door').attr('open', false));
```

### Урон в лаве раз в кадр

```js
$('#hero').overlaps('.lava', (hit) => {
    if (hit) $('#hero').damage(30 * $.time.delta());
});
```

### Ловушка на исчезнувшую цель

```js
$.store.set('kills', 0);
$('<trigger>', { id: 'pit', detect: 'enemy' }).at(600, 500).size(60, 60)
    .appendTo($.world)
    .on('enter', (e) => { e.data.other.kill(); $.store.set('kills', $.store.get('kills') + 1); })
    // Убитый узел исчезает — и это закрывает пересечение событием leave.
    .on('leave', (e) => $.log('из ямы ушёл ' + (e.data.other.get(0).id || 'безымянный')));
```

### Временная зона, созданная из кода

```js
const blast = $.triggers.zone({
    x: 400, y: 300, w: 160, h: 160, detect: 'enemy',
    onEnter: (who) => who.damage(50),
});
$.time.wait(200).then(() => $.triggers.clear());   // убрать все зоны разом
```

---

## 6. Ограничения и решения

* **Прямоугольники, а не тела.** Пересечение считается по габаритам узла
  (`w`/`h`, `hitbox`, масштаб), без обращения к Box2D. Это предсказуемо,
  тестируется без движка и совпадает с `.overlaps`, `$.world.query`.
* **O(зон × целей).** Список узлов собирается один раз за кадр; каждая зона
  фильтрует его по своему `detect`. Для игр на тысячи зон это может стать
  горячим местом — уменьшайте число зон или разносите их по сценам.
* **`clear()` удаляет все зоны мира**, а не только созданные через
  `$.triggers.zone()` — `list()` и `clear()` тогда симметричны.
* **Порядок кадра.** `tickTriggers` вызывается после `ctx.world.sync`,
  поэтому зоны видят свежие позиции тел; события приходят с точностью до кадра.
* **`visible: false` выключает зону целиком.** Невидимая рабочая зона — это
  `alpha: 0` при `visible: true`.
* **Интерфейс не участвует.** Узлы с `attrs.ui` не бывают ни зонами, ни
  целями: они живут в экранных координатах.

---

## 7. Юнит- и интеграционные тесты

| Файл | Что проверяет |
|---|---|
| `tests/js/triggers_test.mjs` | чистые функции и жизненный цикл enter/leave без движка |
| `tests/agent/highlevel_triggers_test.py` | `<trigger>`, `detect`, `$.triggers.*`, `.overlaps` в живом движке |
| `tests/fixtures/triggers/` | игра-фикстура для интеграционного теста |

```bash
build/_deps/quickjs-build/qjs tests/js/triggers_test.mjs
python3 tests/agent/highlevel_triggers_test.py     # после сборки движка
```
