# Реестр ресурсов — `$.resource`

Подсистема даёт ассетам имена: текстуры, спрайты, кадры листов, звуки,
JSON-данные и просто значения из кода регистрируются один раз, грузятся
**лениво** и переиспользуются по имени. Значения кэшируются, у каждого есть
счётчик ссылок, а `free()` освобождает ресурс, когда на него никто не
ссылается.

```js
$.ready(() => {
    // Загрузилось сразу: ссылок 1.
    const tiles = $.resource.load('tiles', 'assets/tiles.png');

    // Описали заранее — загрузится при первом обращении.
    $.resource.define('shot', { kind: 'sound', path: 'sfx/shot.wav' });
    $.resource.define('hero-sheet', { kind: 'sheet', src: 'art/hero.png', cols: 4, rows: 2, cw: 16, ch: 24 });

    $.resource.get('shot');          // здесь и только здесь читается файл
    $.resource.get('hero-sheet');    // массив из 8 кадров (общий кэш ядра)

    // Экран загрузки: прогреваем всё, что описано.
    const { loaded, failed } = $.resource.preload();

    // Уровень закончился — отпускаем ссылки.
    $.resource.free('tiles');
});
```

Три правила, из которых растёт весь модуль:

1. **Имя — это кэш.** Повторный `load('tiles', …)` не перезагружает файл, а
   добавляет ссылку. Другое описание под уже занятым именем не побеждает:
   первое остаётся, в журнал уходит предупреждение.
2. **Загрузка ленивая.** `define()` ничего не читает с диска; файл трогают
   `get()`, `load()`, `reload()` и `preload()`.
3. **Ссылки считаются.** `load()` берёт ресурс (`+1`), `get()` — только
   смотрит (`0`), `free()` отпускает (`−1`); на нуле значение выгружается и
   при следующем обращении грузится заново.

---

## 1. Виды ресурсов

| `kind` | Что возвращает `get`/`load` | Откуда берётся |
|---|---|---|
| `curve` | `function(t)` — кривая | `points` (числа или `{x, y}`), `mode`; `$.curve.makeCurve` |
| `gradient` | `function(t)` — цвет | `stops` (цвета или `{at, color}`), `mode`; `$.curve.makeGradient` |
| `texture` | `number` — id текстуры | `engine.loadTexture(path)`, поле `mipmaps: true` — уровни для уменьшенных спрайтов |
| `sprite` | `number` — id спрайта | общий кэш ядра или `engine.createSprite` для кадра |
| `sheet` | `number[]` — кадры листа | `.frames({ src, cols, rows, cw, ch })` |
| `sound` | `number` — id звука | `engine.audio.load(path)` |
| `json` | объект | `$.fs.readJSON(path, fallback)` |
| `text` | строка | `$.fs.readText(path)` |
| `data` | что угодно | `value` или `build()` из кода |

Вид выводится из расширения, если не указан явно: `.png/.jpg/.bmp/.gif/.webp`
→ `texture`, `.wav/.ogg/.mp3/.flac` → `sound`, `.json` → `json`,
`.txt/.md/.csv/.ini` → `text`, всё остальное → `texture`.

---

### 1.1. Кривые и градиенты

Кривые и градиенты — такие же ресурсы, как текстуры: описываются один раз и
берутся по имени. Отличие одно: их значение задаётся **данными**, а не файлом,
поэтому `path` им не нужен (как виду `data`).

```js
$.resource.define('damage', { kind: 'curve', points: [0, 1, 0.25, 0], mode: 'linear' });
$.resource.define('fire',   { kind: 'gradient', stops: ['#fff2a8', '#ff6b1a', '#7a1f00'] });

const dmg = $.resource.get('damage');   // function(t)
dmg(0.5);          // значение кривой
dmg.range(8);      // восемь отсчётов — для отрисовки или буфера
dmg.at(0.25);      // с зажимом t в 0..1

const fire = $.resource.get('fire');    // function(t) → упакованный цвет
fire(0);           // 4289262335 — тот же формат, что engine.rgba
```

| Вид | Обязательное поле | Ещё принимается |
|---|---|---|
| `curve` | `points` | `values`, `value` (запасная точка), `mode` (`linear`/`step`/`spline`) |
| `gradient` | `stops` | `colors`, `mode` |

**Разные кривые — разные ресурсы.** Ключ описания включает сами данные
(`points`/`values`/`stops`/`colors` и `mode`), поэтому две кривые с одинаковым
видом не склеиваются в одну. Без этого вторая кривая считалась бы «тем же
самым» и вернула бы значение первой — молча.

Кривая без `points` не загружается: `get` вернёт `null`, а причина уйдёт в
журнал (`$.resource.error('имя')`).

## 2. Пространство имён `$.resource`

| Функция | Возвращает | Назначение |
|---|---|---|
| `$.resource.define(name, spec)` | описание / `null` | описать ресурс, не загружая |
| `$.resource.load(name, spec?)` | значение / `null` | взять ресурс: описать (если нужно), загрузить, `+1` ссылка |
| `$.resource.get(name, fallback?)` | значение / `fallback` | получить значение (лениво), ссылку **не** держать |
| `$.resource.reload(name)` | значение / `null` | перезагрузить с диска мимо кэша |
| `$.resource.free(name)` | остаток ссылок (`-1` — нет ресурса) | отпустить ссылку; на нуле — выгрузка |
| `$.resource.freeAll()` | число выгруженных | отпустить все ссылки (описания остаются) |
| `$.resource.preload(names?)` | `{ loaded, failed, total }` | прогреть кэш (без ссылок) |
| `$.resource.has(name)` / `names()` | `bool` / `string[]` | что зарегистрировано |
| `$.resource.info(name)` | описание / `null` | состояние, ссылки, размер текстуры, кадры, длительность звука |
| `$.resource.list()` | массив описаний | все записи без значений (годится в JSON) |
| `$.resource.stats()` | объект | `{ total, ready, defined, failed, refs, loads, fails, kinds }` |
| `$.resource.error(name)` | строка / `null` | последняя ошибка ресурса |
| `$.resource.remove(name)` | `bool` | забыть ресурс вместе со значением |
| `$.resource.clear()` | число | забыть все ресурсы |

`spec` — либо строка-путь, либо объект:

```js
$.resource.define('tiles', 'assets/tiles.png');                 // texture
$.resource.define('shot', { kind: 'sound', path: 'sfx/shot.wav' });
$.resource.define('coin', { kind: 'sprite', src: 'art/coin.png', x: 16, w: 16, h: 16 });
$.resource.define('hero-sheet', { kind: 'sheet', src: 'art/hero.png', cols: 4, rows: 2, cw: 16, ch: 24 });
$.resource.define('config', { kind: 'json', path: 'data/config.json', fallback: {} });
$.resource.define('tuning', { kind: 'data', value: { jump: 640 } });
$.resource.define('wave', { kind: 'data', build: () => makeWave(3) });   // считается один раз
```

`data` — единственный вид, которому не нужны ни движок, ни файлы: значение
берётся из `value` или считается `build()` при первой загрузке и потом
кэшируется. У любого описания может быть `dispose(value)` — он вызывается при
выгрузке (закрыть файл, вернуть что-то движку). Если своего `dispose` нет, у
вида `texture` он появляется сам и возвращает слот движку.

---

## 3. Счётчик ссылок

| Вызов | Ссылки | Что происходит |
|---|---|---|
| `load(name, spec)` | `+1` | грузит, если ещё не загружено; возвращает значение |
| `load(name)` | `+1` | то же для уже описанного ресурса |
| `get(name)` | без изменений | грузит лениво, но не удерживает |
| `reload(name)` | без изменений | выгружает и грузит заново, ссылки сохраняются |
| `free(name)` | `−1` | на нуле: `dispose(value)` (если есть) и значение забыто |
| `freeAll()` | `0` у всех | выгружает всё готовое, описания остаются |
| `remove(name)` / `clear()` | — | забывают ресурс даже при живых ссылках (с предупреждением) |

```js
$.resource.load('tiles', 'assets/tiles.png');   // ссылок 1
$.resource.free('tiles');                       // 0 → значение выгружено
$.resource.get('tiles');                        // снова 0 ссылок, но значение загружено
```

Важно: `get()` не владеет ресурсом. Если `get()`-потребитель держит значение,
а владелец вызвал `free()`, ресурс выгрузится и следующий `get()` загрузит его
заново. Кто грузит надолго — тот и зовёт `load()`.

| Поле `$.resource.info(name)` | Смысл |
|---|---|
| `state` | `defined` (описан), `ready` (загружен), `failed` (не загрузился) |
| `refs` / `loads` / `fails` | ссылок сейчас / успешных загрузок / провалов |
| `path`, `kind` | что и откуда |
| `width`, `height` | размер текстуры (для `texture`) |
| `frames` | число кадров (для `sheet`) |
| `duration` | длительность звука (для `sound`) |
| `error` | текст последней ошибки или `null` |

---

## 4. Ошибки

Провал не бросает исключение: `get()` возвращает `fallback` (по умолчанию
`null`), `load()` — `null`, а в журнал и в `error(name)` уходит сообщение, по
которому понятно, что делать:

```
$.resource: не удалось загрузить "tiles" (texture assets/tiles.png) — текстура "assets/tiles.png" не загрузилась — файл на месте?
$.resource: "shot" не описан — укажите путь: $.resource.load('shot', 'assets/...')
$.resource: не удалось загрузить "config" (json data/config.json) — файл "data/config.json" не найден
```

Упавшая запись остаётся в состоянии `failed` — следующая попытка снова идёт к
загрузчику (файл могли доложить на диск). Число провалов видно в `stats().fails`.

---

## 5. Ограничения

* **Текстуры выгружаются, звуки — пока нет.** `free()` у текстуры зовёт
  `engine.freeTexture(id)`: GPU-память освобождается, слот возвращается движку и
  переиспользуется следующей загрузкой (лимит — 256 текстур), а кэши ядра
  забывают путь. У звука обратной функции в API нет, поэтому `free()` только
  забывает значение. Белую текстуру движка выгрузить нельзя — она основа
  `drawRect` и nine-slice.
* **Реестр не знает про сцены.** `$.scene.load()` ресурсы не выгружает —
  вызывайте `freeAll()`/`clear()` сами, когда уровень закончился.
* **`preload()` не держит ссылок.** Прогрели кэш — он останется, пока кто-то
  не вызовет `free()`/`remove()`; `freeAll()` после `preload()` тоже выгрузит
  (ссылок нет, `refs = 0`).
* **Имя ресурса — строка.** Регистр учитывается (`Tiles` и `tiles` — разные),
  пробелы по краям срезаются.
* **Кадр (`sprite`) без обрезки идёт через общий кэш ядра.** Это тот же
  спрайт, что у `.sprite('path')`; с обрезкой (`x/y/w/h`) создаётся свой
  спрайт на текстуре.
* **`json`/`text` требуют `$.fs`** (модуль `store.js`). Без него ресурс
  останется в состоянии `failed` с подсказкой «нет $.fs».

---

## 6. Чистые функции (тесты без движка)

Ядро реестра не касается `engine` — его можно проверить под qjs
(`tests/js/resource_test.mjs`):

| Функция | Смысл |
|---|---|
| `inferKind(path)` | вид ресурса по расширению |
| `normalizeSpec(name, spec)` | строка/объект → нормализованное описание |
| `specKey(spec)` / `describeSpec(spec)` | ключ сравнения и текст «что это» |
| `makeEntry(spec)` | новая запись реестра |
| `errorText(entry, reason)` | текст ошибки с именем и путём |
| `ensureLoaded(entry, loader, now)` | ленивая загрузка с кэшем (повторно не грузит) |
| `acquireEntry(entry, loader, now)` | загрузить и добавить ссылку |
| `releaseEntry(entry, onError)` | отпустить ссылку, на нуле — выгрузить |
| `unloadEntry(entry, onError)` | выгрузить значение (вызывает `dispose`) |
| `createRegistry(loader, opts)` | реестр целиком: `define/acquire/peek/free/…` |
