1. 07.10.2026 21 коммит
    • Nikitos's avatar
      Фазы 6-8: утверждения $.expect, реактивные запросы $.watch, DevTools...
      · e8ae8bcb
      Nikitos создал
      Фазы 6-8: утверждения $.expect, реактивные запросы $.watch, DevTools $.devtools на RmlUi, engine.ui.loadMarkup v0.1.16
      e8ae8bcb
    • Nikitos's avatar
      Запросы в радиусе и within(), команды query/inspect/profile, запись и...
      · d56510dd
      Nikitos создал
      Запросы в радиусе и within(), команды query/inspect/profile, запись и воспроизведение ввода (--record/--replay), законы движка и правила агентов, сверка доков с кодом v0.1.15
      d56510dd
    • Nikitos's avatar
      Ассеты игры ищутся в каталоге игры (--game), а не только в каталоге движка
      · 1a1e6ab5
      Nikitos создал
      НАЙДЕНО ПРИ СБОРКЕ audm-neko В ОТДЕЛЬНОЙ ПАПКЕ. `--game <каталог>` выбирал
      точку входа (main.js), но `r2d_app_resolve_path` знал только `base_path` —
      каталог, откуда запущен движок. Поэтому игра в СВОЕЙ папке не находила ни
      одного своего ассета: `assets/tiles/hideout_atlas.png` резолвился в каталог
      движка и возвращал -1. Тайлмап при этом исправно держал коллизии (они в данных
      карты), так что игра «работала» — просто без картинок.
      
      ЧТО СДЕЛАНО:
      * R2DApp получил game_path (каталог игры) и r2d_app_set_game_path();
      * `--game` теперь задаёт его (относительный путь делается абсолютным от cwd);
      * r2d_app_resolve_path: относительный путь сначала проверяется В КАТАЛОГЕ ИГРЫ
        (именно проверкой существования файла), и лишь потом склеивается с base_path.
        Так встроенные шрифты и иконки движка, которых в игре нет, остаются
        доступны, а игра может держать свои ассеты рядом с собой.
      
      ПРОВЕРКА: tests/agent/highlevel_game_path_test.py создаёт НАСТОЯЩУЮ игру во
      временном каталоге со своим PNG (генерируется без внешних библиотек),
      project.json и main.js, запускает её и убеждается, что `assets/probe.png`
      грузится (8x8, id >= 0), несуществующий ассет по-прежнему даёт -1, а абсолютный
      путь возвращает ту же текстуру.
      
      ДОКИ: API.md §6 — порядок поиска путей с объяснением, почему одного base_path
      мало.
      
      Полный qjs, агентские тесты (включая resource, atlas, mipmap, text — они грузят
      ассеты по путям), doc_coverage, doc_claims, быстрый набор — зелёные.
      1a1e6ab5
    • Nikitos's avatar
      Звук: seek/position и приоритеты каналов вместо «глушим нулевой»
      · 9a5c064e
      Nikitos создал
      Закрывает два пункта §11 «осталось незакрытым».
      
      ЧТО БЫЛО НЕ ТАК: r2d_audio_play при нехватке каналов ВСЕГДА переиспользовал
      канал 0 — то есть важная реплика глушилась первым же шагом по траве, а
      приоритета не существовало вовсе. Перемотки тоже не было.
      
      ЧТО СДЕЛАНО:
      * C: r2d_audio_play(..., int priority) — ищет свободный канал, иначе вытесняет
        САМЫЙ НЕВАЖНЫЙ и только если новый не менее важен; иначе -1 (лучше не играть,
        чем заглушить важное). Приоритет канала хранится в a->channel_priority;
      * C: r2d_audio_seek / r2d_audio_position / r2d_audio_channel_duration /
        r2d_audio_channel_priority — через MIX_Set/GetTrackPlaybackPosition (каналы
        движка это MIX_Track) и MIX_GetAudioDuration; кадры переводятся в секунды по
        частоте микшера;
      * engine.audio.play(id, volume, pan, loop, priority), seek, position,
        channelDuration, channelPriority, channelCount;
      * $.sound.play(..., { priority }), seek, position, durationOf, priorityOf, busy.
      
      ПРОВЕРКА (tests/agent/highlevel_sound_seek_test.py, 22 проверки). Файл звука
      тест ГЕНЕРИРУЕТ сам (2 с, 440 Гц) — в фикстурах звуков нет. Проверено: позиция
      растёт; seek(0.30) даёт позицию ≈0.30; перемотка в конец освобождает канал; на
      неиграющем канале seek → false, position → -1; при 16 занятых каналах важный
      звук (9) вытесняет неважный и НЕ глушит другой важный; слабый (-5) получает -1 и
      важные остаются; $.sound.priorityOf/durationOf/busy работают.
      
      МОЯ ЖЕ ОШИБКА В ТЕСТЕ, из-за которой сначала было три ложных провала: я передал
      ЛИШНИЙ ноль — play(id,1,0,0,0,pr) вместо play(id,1,0,0,pr) — и приоритет уехал
      на шестой аргумент, которого нет. Плюс тон 0.5 с успевал доиграть между
      проверками, и «отказ» случался по другой причине. Исправлено и отмечено
      комментарием в тесте.
      
      ДОКИ: sound.md §2.1 (приоритеты, перемотка, зачем вытеснять неважного),
      API.md (одна строка на функцию), TASKS §11 — из остатков убраны оба пункта.
      
      Полный qjs, агентские тесты, doc_coverage, doc_claims, duplicate_keys, быстрый
      набор — зелёные.
      9a5c064e
    • Nikitos's avatar
      Мипмапы текстур: engine.loadTexture(path, { mipmaps: true })
      · 8813d966
      Nikitos создал
      §12.4 «фильтрация/мипмапы»: фильтрация была (nearest/linear), мипмапов не было
      вовсе. Без них УМЕНЬШЕННЫЙ спрайт мерцает — сэмплер берёт одну точку из большой
      картинки.
      
      ЧТО СДЕЛАНО:
      * render.c: r2d__mip_levels (полная пирамида, предел SDL 16), создание текстуры
        с нужным числом уровней, r2d_texture_load_mipped;
      * в r2d__upload_pixels после копирования — SDL_GenerateMipmapsForGPUTexture;
      * engine.loadTexture(path, { mipmaps: true });
      * $.resource: поле `mipmaps: true` в описании текстуры.
      
      ДВЕ ОШИБКИ SDL, которые поймал тест (обе — ассерты; в сборке с отключёнными
      ассертами это были бы молча чёрные текстуры, а не отказ):
      1. «Cannot generate mipmaps for texture with num_levels <= 1» — я добавил
         параметр уровней, но замена `num_levels = 1` попала не в ту функцию
         (в файле две почти одинаковые функции создания текстуры), и уровень остался
         один;
      2. «must be created with SAMPLER and COLOR_TARGET usage flags» — SDL строит
         уровни, РИСУЯ их, поэтому мипмап-текстуре нужен COLOR_TARGET
         (`levels > 1` добавляет флаг; одноуровневым он не нужен).
      
      УРОК ПРО МОИ ЖЕ ЗАМЕНЫ: `python replace` без проверки результата дважды сработал
      не туда — сначала в create_texture_usage, потом молча ничего. Теперь после
      каждой замены проверяю, что она нашлась.
      
      ПРОВЕРКА (tests/agent/highlevel_mipmap_test.py, 9 проверок): текстура с
      мипмапами получает валидный id; кэш по пути возвращает тот же слот; спрайт
      рисуется в натуральную величину и УМЕНЬШЕННЫМ (1929 точек против 40153 — то
      есть уменьшение реально); $.resource грузит текстуру с mipmaps: true;
      textureFromPixels не затронут.
      
      ЧЕСТНОЕ ОГРАНИЧЕНИЕ (записано в API.md): кэш идёт по ПУТИ, поэтому если текстура
      уже загружена без мипмапов, запрос с mipmaps: true вернёт ту же — опции не
      переприменяются. Загружайте с мипмапами сразу.
      
      ДОКИ: API.md (опция, зачем, ограничение кэша), resource.md (поле mipmaps),
      TASKS §12.4. Осталось из §12.4: Curve/Gradient как ресурсы ($.curve умеет
      кривые и градиенты, но видов в $.resource нет).
      
      Полный qjs, агентские тесты, doc_coverage, doc_claims, duplicate_keys, быстрый
      набор — зелёные.
      8813d966
    • Nikitos's avatar
      Несколько форм на тело: зоны «голова»/«ноги» через .zone()
      · 53fc4716
      Nikitos создал
      Последний хвост §1.3: у тела движка была ОДНА форма, поэтому «попал в голову, а
      не в ногу» было недостижимо. Теперь у тела до 8 форм-зон, каждая со смещением от
      центра, а в контакте видно, КАКАЯ форма столкнулась.
      
      ЧТО СДЕЛАНО:
      * C: r2d_physics_add_shape(p, id, desc, dx, dy) и r2d_physics_shape_count;
        user data формы теперь кодирует И тело, И номер формы: (id+1) << 8 | index.
        Появился r2d__shape_index_of_shape; contact_between/contacts_of отдают индексы
        форм (shapeA/shapeB, shape/shapeOther);
      * engine.addShape(body, desc), engine.shapeCount(body);
      * $.world.zone(node, {type, w, h, x, y, tag, sensor, ...}), zoneTag, zoneCount,
        zonesTouching; метод узла .zone({...}).
      
      Имя метода — `zone`, а НЕ `shape`: `.shape()` уже занято формой хитбокса, и мой
      первый вариант молча перезаписался вторым def (это и обнаружилось тем, что
      `.shape({...})` возвращал обёртку, а формы не добавлялись).
      
      СМЕЩЕНИЕ ЗОНЫ — ОТДЕЛЬНЫЕ ПАРАМЕТРЫ dx/dy, а не поля описания тела. Сначала я
      положил смещение в R2DBodyDesc (shape_dx/shape_dy), и это тихо сломало ВСЕ
      коллизии: тела стали пролетать сквозь стены. Внешне всё выглядело здоровым —
      тела создавались, массы были правильные (смещение не меняет площадь), луч видел
      стены. Диагноз дал только контрольный опыт: с инлайн-созданием формы коллизии
      возвращались, а через helper — нет.
      
      ТРИ ГРАБЛИ, на которые я наступил, и что их ловит:
      1. `&sd` вместо `sd` в helper — движок падал с SIGTRAP на старте. Компилятор
         предупреждал, а я warnings не смотрел; теперь смотрю.
      2. Смещение в описании тела — сломало коллизии (см. выше).
      3. Имя `.shape()` занято — переименовал в `.zone()`.
      
      ПРОВЕРКА (tests/agent/highlevel_zones_test.py, 16 проверок): зоны получают
      индексы 1 и 2 (0 — основная форма), теги читаются; и главное — ФИЗИКА: пол
      касается формы 2 → тега `legs`, а НЕ головы; на втором теле стена касается зоны
      `foot`, а не основной формы. То есть зоны действительно разнесены в пространстве,
      а не «настройка сохранилась».
      
      ЧЕСТНО ПРО ГРАБЛИ В ТЕСТЕ: проверка «шип падает сверху в голову» оказалась
      хрупкой — шип у меня проскакивал мимо, а зоны часто перекрываются, и тогда пара
      касается нескольких форм сразу. Поэтому проверяю через `contactsOf` (какая форма
      лежит на полу) и добавил `zonesTouching` — он отдаёт ВСЕ задетые зоны, а не
      первую. Это записано в world.md §2.3.
      
      ДОКИ: world.md §2.3 (поля зоны, перекрытие, предел 8 форм), API.md
      (engine.addShape с прямым предупреждением, что x/y — смещение, а не позиция),
      TASKS §1.3 и §12.6.
      
      Полный qjs, агентские тесты (включая contact и tug, которые эту регрессию и
      поймали), doc_coverage, doc_claims, duplicate_keys, быстрый набор — зелёные.
      53fc4716
    • Nikitos's avatar
      Симуляция задержки сети: очередь отложенных отправок
      · 9a5b45c5
      Nikitos создал
      §12.15: «симуляция задержки пакетов» числилась незакрытой — и в C это было
      написано прямым текстом: `R2D_UNUSED(delay_ms); // задержку пока не откладываем:
      только потери`. Потери работали, задержки не было.
      
      ЧТО СДЕЛАНО:
      * C: r2d_net_send разделён на обёртку (симуляция) и r2d__net_send_now (реальная
        отправка). Задержка — ОЧЕРЕДЬ отложенных отправок (64 пакета): пакет кладётся
        со временем «когда отправить», реально уходит из r2d_net_tick, который зовёт
        poll() раз в кадр. Спать в кадре нельзя — поэтому именно очередь;
      * jitter: случайная добавка [0, jitter) к задержке, из того же xorshift, что и
        потери, поэтому воспроизводимо от сида;
      * потери применяются ПРИ ПОСТАНОВКЕ, поэтому потерянный пакет очередь не
        занимает;
      * очередь переполнена — ОДНО предупреждение в журнал и пакет теряется, кадр не
        роняется;
      * engine.netSimulate(loss, delay, seed, jitter), engine.netDelayed();
      * $.net.simulate({loss, delay, jitter, seed}), simulation(), delayed(),
        simulateOff().
      
      ПРОВЕРКА (tests/agent/net_delay_test.py, 14 проверок, ДВА движка на localhost):
      без задержки пакет доходит быстро; с delay 200 сразу после send пакет В ОЧЕРЕДИ
      (netDelayed = 1) и до сервера НЕ дошёл, а через ~0.16 с дошёл; simulateOff
      возвращает быстроту; 100% потерь — не доходит ничего. То есть проверяется не
      «настройка сохранилась», а что задержка ДЕЙСТВИТЕЛЬНО задерживает.
      
      ЧЕСТНОЕ ОГРАНИЧЕНИЕ: симуляция не моделирует переупорядочивание и дубли —
      пакеты теряются и задерживаются, но не приходят в другом порядке. Записано в
      net.md §8.
      
      ДОКИ: net.md §8 (поля, почему очередь, ограничения), API.md (две функции),
      TASKS §12.15 (осталось только сглаживание откатов).
      
      Полный qjs, агентские тесты, doc_coverage, doc_claims, duplicate_keys, быстрый
      набор — зелёные.
      9a5b45c5
    • Nikitos's avatar
      Контакты: импульс, точки и «касаются ли сейчас»
      · 06543427
      Nikitos создал
      Закрываю §1.3 в части, которая достижима: события контакта говорят, что
      СТОЛКНУЛОСЬ, но импульса в них НЕТ — солвер считает его после события. Поэтому
      «сила удара» (по ней считают урон) была недостижима, как и вопрос «касаются ли
      эти двое прямо сейчас».
      
      ЧТО СДЕЛАНО (через b2Body_GetContactData — манифолд с импульсом предыдущего шага):
      * C: r2d_physics_contact_between (импульс, число точек, нормаль) и
        r2d_physics_contacts_of (с кем и с каким импульсом);
      * engine.touching(a, b) → bool, engine.contactBetween(a, b) → {impulse, points,
        nx, ny} | null, engine.contactsOf(id, cap) → [{other, impulse, points}];
      * $.world.touching / contactBetween / contactImpulse / contactsOf.
      
      Импульс берём НАИБОЛЬШИЙ по точкам манифолда: «сила удара» — это удар, а не
      сумма касаний.
      
      ПРОВЕРКА (tests/agent/highlevel_contact_test.py, 16 проверок): в полёте контакта
      нет; на полу touching true, импульс 0.1222 при 2 точках контакта и вертикальной
      нормали; далёкий узел не касается; contactsOf перечисляет тело с импульсом; и
      главное — ПИК УДАРА 0.2249 против покоя 0.1222, то есть по импульсу можно
      отличить удар от лежания.
      
      ТОНКОСТЬ, которую стоит помнить: импульс удара виден на кадре СТОЛКНОВЕНИЯ, а
      touching в этот момент ещё false — манифолд появляется на следующем шаге. Поэтому
      пик ищется чтением импульса КАЖДЫЙ кадр, а не только когда касание уже есть. Я на
      этом обжёгся в тесте и записал в доки.
      
      ЧЕСТНО ПРО НЕДОСТАЮЩЕЕ: нескольких ФОРМ на тело нет — у тела движка одна форма,
      поэтому «попал в голову, а не в ногу» пока недостижимо: нужны несколько форм на
      тело, и это отдельная работа. Форма не различается и в событиях контакта.
      Записано в API.md и world.md, чтобы не искали field, которого нет.
      
      ДОКИ: world.md §2.2 (таблица полей, момент чтения), API.md (три функции),
      TASKS §1.3 переписан.
      
      Полный qjs, агентские тесты, doc_coverage, doc_claims, duplicate_keys, быстрый
      набор — зелёные.
      06543427
    • Nikitos's avatar
      Обрезка (scissor): $.gfx.clip и .clip() у узла
      · f714982d
      Nikitos создал
      §4.2 назвал дыру прямо: «grep scissor/clipRect = 0 — нет ни scissor, ни clip».
      Из-за этого не работали прокрутка списка, портрет в рамке и миникарта. В SDL3 GPU
      оказался SDL_SetGPUScissor (с 3.2.0), так что stencil для этого не понадобился —
      взял scissor, он закрывает ровно названную дыру.
      
      ЧТО СДЕЛАНО:
      * C: таблица обрезок кадра (R2D_MAX_CLIPS 256), r2d_render_set_clip/clear_clip/
        get_clip/clip_count; каждая команда помнит СВОЮ обрезку (R2DDrawCmd.clip), а
        проход рвёт участок по клипу и ставит scissor перед рисованием. Повтор той же
        обрезки не занимает новый слот — иначе скролл переполнил бы таблицу за секунду;
      * engine.setClip / clearClip / getClip / clipCount;
      * $.gfx.clip(x, y, w, h) | ({rect}) | (узел), clipOff(), clipRect(), clipCount();
      * .clip(true | {x,y,w,h} | false) у узла — обрезка по своей коробке или по
        прямоугольнику.
      
      КЛЮЧЕВОЕ: обрезка действует на КОМАНДУ, а не на кадр. Батч кадра один, но каждый
      спрайт помнит свой прямоугольник, и отправка рвётся по клипу. Поэтому РАЗНЫЕ УЗЛЫ
      ОДНОГО КАДРА обрезаются по-разному — это и проверено тестом (два узла, два клипа,
      3600 пикселей каждый).
      
      ЕЩЁ ОДНА ТОНКОСТЬ, найденная по дороге: сброс обрезок стоял в начале _render, а
      игра ставит клип в $.render, который идёт РАНЬШЕ — клип стирался перед самой
      отрисовкой. Сброс перенесён в начало кадра отрисовки (setRender).
      
      ПРОВЕРКА (tests/agent/highlevel_clip_test.py, по пикселям): без обрезки узел
      виден весь; клип 600,500,100,80 даёт ровно 600..699 x 500..579; clipOff возвращает
      целый узел; обрезка узла НЕ трогает соседа (не «растекается»); два узла одного
      кадра обрезаются каждый своим; нулевой прямоугольник снимает обрезку.
      
      ДОКИ: render.md §5.1 (как работает, где вызывать, таблица вызовов, ограничения —
      обрезка узла не наследуется детьми), API.md (engine.setClip и родня), §1.6 TASKS
      переписан из «нет scissor/clip» в «сделано».
      
      Полный qjs, агентские тесты, doc_coverage, doc_claims, duplicate_keys, быстрый
      набор — зелёные.
      f714982d
    • Nikitos's avatar
      Текстурированный меш: UV и id текстуры в engine.submitMesh
      · f0fb28e7
      Nikitos создал
      Первый пункт §4.1 («текстурированные треугольники и меш»). Раньше `r2d_batch_mesh`
      всегда биндил БЕЛУЮ текстуру, и `u`/`v` были мертвы — текстурированный псевдо-3D
      был невозможен. Теперь:
      
      * `R2DTriBatch` помнит `texture` — id текстуры пакета;
      * `engine.submitMesh(vertices, count?, texture?)`: третий аргумент — id из
        `engine.loadTexture` / `engine.textureFromPixels` / `$.atlas`; без него белая
        текстура, как раньше;
      * `r2d_render_draw_mesh` биндит текстуру пакета, а не жёстко белую.
      
      ПРОВЕРЕНО ПО ПИКСЕЛЯМ (tests/agent/highlevel_mesh_test.py): текстура 2x1
      «красный | зелёный», u=0 слева → красная половина 100..199, u=1 справа → зелёная
      200..299. Плюс прежние проверки: меш ровно по вершинам, z-буфер отсекает дальний
      треугольник независимо от порядка, 100 треугольников без падения.
      
      ЗАОДНО ИСПРАВЛЕНА ЛОЖЬ В ДОКЕ: `r`/`g`/`b` у вершин — 0..255 (как у drawRect),
      а не 0..1. `r2d__color_f32` клампит в 255, поэтому игрок, следующий доке,
      получал почти чёрный меш — я сам на этом обжёгся при первом замере. Поправлено
      в комментарии script.c, в API.md (там же появился пример с текстурой) и в
      depth.md.
      
      ТЕСТ ПЕРЕПИСАН на ОДИН обработчик `$.update`: он накапливает функции, и каждый
      новый кадр рисовал бы все меши сразу — более поздний перекрывал предыдущий, и
      проверки UV «падали» из-за моего же теста. Второй аргумент `undefined` движок
      читает как count=0, поэтому count считается в JS явно.
      
      ОСТАЛОСЬ ИЗ §4.1: `$.mesh` (вершины с UV, веса на 1-2 кости, скелет-дерево,
      CPU-деформация без аллокаций) и извлечение тегов/пивотов/костей из Aseprite JSON
      (сам импорт атласов уже есть в `$.atlas`). §4.2 (stencil) тоже не сделан —
      depth-stencil формат сейчас только depth.
      
      Полный qjs, агентские тесты, doc_coverage, doc_claims, быстрый набор — зелёные.
      f0fb28e7
    • Nikitos's avatar
      Меш псевдо-3D работает: причина была в непривязанном индексном буфере
      · 662bb89e
      Nikitos создал
      Пункт §12.13 ЗАКРЫТ. Меш рисуется, z-буфер работает.
      
      ПРИЧИНА, которую я искал замерами и не находил: в r2d_render_draw_mesh НЕ
      вызывался SDL_BindGPUIndexBuffer. Меш рисуется ПЕРВЫМ в проходе сцены (он
      пишет глубину, по которой потом проверяются спрайты), а индексный буфер
      привязывают участки спрайтов и треугольников — то есть ПОЗЖЕ. Поэтому
      SDL_DrawGPUIndexedPrimitives уходил с непривязанным индексным буфером, и Metal
      падал с SIGSEGV. Лечится одной привязкой в начале функции.
      
      Нашлось чтением, а не замером: я перечитал draw_mesh целиком и заметил, что
      привязок индексного буфера в нём НОЛЬ, тогда как в спрайтовом проходе их две.
      
      ПОЧЕМУ ПРОБЫ НЕ НАХОДИЛИ ЭТО РАНЬШЕ: в функции стоял ранний return ДО кода
      отрисовки, поэтому «падает с записью глубины» и «работает без записи» означали
      одно и то же — отрисовки не было. А «$.gfx.depth(false) спасает» объясняется
      первой строкой функции: if (!r->depth_enabled) return — то есть меш просто не
      рисовался. Выводы из тех проб убраны и из кода, и из документации: комментарий
      про «LESS валит Metal, держимся на GREATER, глубина обратная» в render.c врал
      (сравнение всё время было LESS_OR_EQUAL).
      
      ПРОВЕРЕНО (tests/agent/highlevel_mesh_test.py, по пикселям):
      * меш рисуется ровно по заданным вершинам (квадрат 100..300 x 100..300);
      * z-буфер РАБОТАЕТ, а не painter's algorithm: ближний (z=0.2) нарисован
        ПЕРВЫМ, дальний (z=0.8) ВТОРЫМ — дальний отсечён; обратный порядок даёт то же;
      * 100 треугольников (300 вершин), 10 кадров — без падения.
      
      Заодно обновлён highlevel_depth_test (он проверял старое поведение «отрисовка
      отключена защитой»).
      
      ЧЕСТНОЕ ОГРАНИЧЕНИЕ: спрайтовый шейдер пишет z = 0 («ближе всего»), поэтому
      СПРАЙТ ВСЕГДА ПОВЕРХ МЕША, каким бы близким меш ни был; z-буфер сортирует
      только треугольники меша между собой. Чтобы спрайт мог оказаться ЗА выпуклостью
      персонажа, спрайтам нужна своя глубина — это отдельная работа, и она не сделана.
      Записано в depth.md §4 и в API.md.
      
      ДОКИ: в API.md добавлен раздел engine.submitMesh (его там не было вовсе) с
      форматом вершин, порядком, границами и диагностикой; depth.md §4 переписан из
      «отключено» в «работает» с причиной; §12.13 обновлён.
      
      Полный qjs, агентские тесты (16 наборов), r2d_bsp_test, doc_coverage,
      doc_claims, duplicate_keys, быстрый набор — зелёные.
      662bb89e
    • Nikitos's avatar
      CCD: почему туннелирование не воспроизводится — предел скорости движка
      · 6eb919e5
      Nikitos создал
      Довёл CCD до состояния «проверено и объяснено» вместо «не удалось
      воспроизвести». Прежняя запись в §12 советовала «взять стену тоньше и подшаг
      мельче» — совет был неверен, и вот почему.
      
      ЧТО ВЫЯСНИЛОСЬ:
      * туннелирование не воспроизводится НИ с CCD, ни без него: 2-пиксельная стена
        при 63 px за шаг (3800 px/с) тело останавливает (x = 1995 при стене на 2000);
      * предел скорости задан `def.maximumLinearSpeed = 120` метров в секунду. При
        32 px/м это ≈ 3840 px/с ≈ 64 px за шаг 1/60 — БЫСТРЕЕ ТЕЛО НЕ РАЗОГНАТЬ:
        `engine.setVelocity(body, 60000, 0)` даёт на выходе ровно 3840;
      * поэтому «подшаг мельче» не поможет: подшаг и так 1/60, а упереться можно
        только в предел скорости. Box2D v3 решает высокоскоростные контакты
        спекулятивно, и на пределе скорости этого хватает даже для стены 2 px.
      
      Это и была причина прежнего «не удалось воспроизвести»: тест пытался разогнать
      тело до 60000 px/с, но получал 3840 — то есть обычную скорость, на которой
      туннелирования и не должно быть. Заодно объяснилось «тело встало на 2660»:
      это не стена, а предел скорости (3840 px/с × 40 кадров ≈ 2560 px).
      
      ЗНАЧИТ `bullet` — страховка на будущее (если поднимать maximumLinearSpeed) и
      корректная настройка для сложных сцен, а не наблюдаемый сейчас эффект. Так и
      записано в API.md §15, вместе с новой строкой таблицы лимитов: 120 м/с ≈ 3840
      px/с, быстрее setVelocity не разгонит.
      
      Проверка (tests/agent/highlevel_ccd_test.py, 8): флаг доходит до Box2D и
      читается обратно, снимается; пуля на ПРЕДЕЛЕ скорости останавливается и о
      2-пиксельную, и о 8-пиксельную стену, с CCD и без него; тело долетает до стены,
      а не встаёт раньше; контроль на обычной скорости.
      
      Полный qjs, агентские, doc_coverage, doc_claims, быстрый набор — зелёные.
      6eb919e5
    • Nikitos's avatar
      Буфер обмена и предпросмотр IME: Ctrl+C/V/X/A и выделение в <ui.input>
      · a8394d66
      Nikitos создал
      Последний пункт P2, не требующий правок в main.c. Ни буфера обмена, ни
      предпросмотра композиции IME в движке не было вовсе: SDL_EVENT_TEXT_EDITING не
      обрабатывался, SDL_SetClipboardText/GetClipboardText не вызывались, в поле ввода
      не было ни Ctrl+C/V/X/A, ни выделения, а буфер кадра был 256 байт.
      
      ДВИЖОК:
      * r2d_app_clipboard/set_clipboard (SDL3, копию освобождаем сами), текст
        композиции IME и её начало, r2d_app_set_text_input_area для окна кандидатов;
      * обработка SDL_EVENT_TEXT_EDITING: незавершённая композиция показывается
        подчёркнутой и НЕ вставляется — текст придёт TEXT_INPUT;
      * буферы текста и композиции увеличены 256 → 1024/256;
      * engine.clipboard(), engine.setClipboard(text), engine.ime(), engine.textInputArea().
      
      ПОЛЕ ВВОДА:
      * Ctrl+C/X/V/A и классические Shift+Insert/Shift+Delete/Ctrl+Insert;
      * выделение: Shift+стрелки/Home/End с ЯКОРЕМ, Ctrl+A, вставка и удаление
        работают с выделением (вставка его заменяет), maxLength соблюдается и при
        вставке;
      * окно IME показывается у поля, а не в углу окна;
      * $.input.ctrlDown()/shiftDown()/altDown() — модификаторы доступны игре.
      
      НАЙДЕН И ИСПРАВЛЕН ДЕФЕКТ В САМОМ ВЫДЕЛЕНИИ: при Shift+стрелке якорь не
      ставился (sel_from оставался -1), то есть выделения не возникало, и Backspace
      удалял ОДИН символ вместо выделенного куска. Нашлось тестом на точное поведение
      Backspace с выделением.
      
      ЧЕСТНО ПРО ТЕСТ: системного буфера в headless-прогоне нет, поэтому тест
      подменяет engine.clipboard на стороне JS и проверяет ГОРЯЧИЕ КЛАВИШИ и логику
      правки, а не платформенный буфер. Это записано в тесте и в widgets.md.
      
      ЧЕСТНО ПРО ГРАНИЦЫ: выделение не подсвечивается и мышью не выделяется —
      только клавиатурой; перетаскивания выделения нет.
      
      Заодно убрано ложное утверждение в API.md: текст приходит из TEXT_INPUT «с
      учётом IME» — это только завершённый ввод; незавершённая композиция теперь
      описана отдельно.
      
      Проверка: tests/agent/highlevel_clipboard_test.py (17 проверок: ввод, Ctrl+A/C/V/X,
      возврат текста, Backspace с выделением и без, выделение Shift+стрелками, вставка
      вместо выделения, обрезка по maxLength, engine.ime, модификаторы). Полный набор
      qjs, агентские тесты, doc_coverage, doc_claims, duplicate_keys, быстрый набор —
      зелёные.
      a8394d66
    • Nikitos's avatar
      §10: лимиты видны игре, рассинхрон слоёв исправлен
      · 278cbe7d
      Nikitos создал
      Разобран §10. Главная проблема была не в самих числах, а в том, что игре нечем
      узнать, близко ли она к потолку, и что поведение при достижении разное — где-то
      -1, где-то исключение, где-то тихая потеря цвета.
      
      ЧТО СДЕЛАНО:
      * $.debug.limits() отдаёт ЗАНЯТОСТЬ, а не только потолки: вьюпорты,
        обработчики UI, события контакта, эффекты узлов, геймпады, касания. Раньше
        вьюпорты/обработчики/документы/контакты отдавали только максимум — то есть
        «где-то есть лимит», но не «сколько осталось»;
      * ИСПРАВЛЕН РАССИНХРОН СЛОЁВ: JS держал MAX_NODE_FX = 60, а C принимает 64 —
        четыре лишних эффекта JS терял молча. Теперь предел один (64, как в render.h);
      * новый r2d_render_viewport_live_count: занятость слотов вьюпорта;
      * API.md §14: ОДНА таблица всех лимитов с поведением при достижении (суставы,
        эффекты узла, шейдеры, вьюпорты, контакты, очередь текста, запросы к физике,
        группы звука, подписчики SDL, обработчики UI, документы, геймпады, касания) и
        указанием на $.debug.limits() для рантайма;
      * §10 переписан как «закрыто» с перечислением, что именно было не так,
        включая два пункта, о которых доки молчали: слоты вьюпортов берутся из общего
        бюджета 256 текстур, а «лимит 64 документа» не смертелен — слоты
        переиспользуются.
      
      Проверка (tests/agent/highlevel_limits_test.py, 15 проверок): отчёт отдаёт 23
      поля, потолки совпадают с C (эффекты 64, вьюпорты 8, шейдеры 16, геймпады 4),
      занятость тел/суставов/вьюпортов РЕАЛЬНО растёт от действий игры, после
      destroy вьюпорта возвращается, занятость нигде не выше потолка. Полный набор
      qjs, агентские тесты, doc_coverage, doc_claims, быстрый набор — зелёные.
      278cbe7d
    • Nikitos's avatar
      Touch и мультигеймпад: четыре слота, до десяти пальцев
      · 6ebad69f
      Nikitos создал
      Последние пункты §12.6, которые прямо влияют на игру. До этого был ОДИН
      геймпад («подключён первый») и не было ни одного тач-события: локальная игра
      вдвоём и мобильные порты были невозможны.
      
      ГЕЙМПАДЫ (до четырёх слотов):
      * app: gamepads[4] со своими кнопками и осями, открытие всех подключённых,
        освобождение слота при отключении; старые r2d_pad_down/axis читают слот 0;
      * engine.padCount/padSlots/padConnectedAt/padDownAt/padPressedAt/padAxisAt/
        padRumbleAt; $.input.gamepad(slot) и $.input.padCount()/padSlots().
      
      КАСАНИЯ (до десяти пальцев):
      * app: позиция, сдвиг за кадр, давление; обработка SDL_EVENT_FINGER_DOWN/
        MOTION/UP с переводом из нормализованных координат в точки окна;
      * engine.touchCount/touch/touchDelta/touchDown/touchPressure;
        $.input.touches()/touchCount()/touch(i)/touched().
      
      АГЕНТ: команды touch (down/move/up/clear) и pad (кнопка и ось) — касания и
      геймпады иначе в CI не проверить.
      
      ЧЕТЫРЕ ДЕФЕКТА, НАЙДЕННЫЕ ТЕСТАМИ:
      1) клиент протокола подставляет в поле `id` номер запроса, поэтому `id: 0`
         превращался в 1 и палец попадал в слот 1 — номер пальца теперь в `finger`;
      2) виртуальный геймпад накладывался ДО опроса SDL, и на устройстве, которого
         нет, SDL обнулял состояние в том же кадре — теперь поверх опроса;
      3) «предыдущий» кадр геймпада копировался до наложения виртуального ввода, и
         фронт нажатия был не виден — копирование перенесено в конец кадра;
      4) в input.js было ДВА метода gamepad, старый перекрывал новый (молча: JS
         разрешает повтор ключа) — старый убран, новый берёт движок через engineOf.
      
      Проверка (tests/agent/highlevel_touch_pad_test.py, 21 проверка): палец, сдвиг
      40 px за кадр, два пальца одновременно, отпускание, clear; кнопка и ось слота 1
      (слот 0 не задет), фронт нажатия, честный отказ вибро без устройства. Полный
      набор qjs, агентских тестов, модули, doc_coverage, doc_claims, быстрый набор —
      зелёные.
      6ebad69f
    • Nikitos's avatar
      Перетаскивание: мёртвый mouse-сустав убран, вместо него $.world.tug
      · 8d2162dd
      Nikitos создал
      Разбирался, почему mouse-сустав Box2D не тянет тело. Ответ: не тянет ни при
      какой силе (проверены 10, 100, 1000, 10000, 100000, 500000 Н), при выключенном
      сне, при явном пробуждении, с целью в правильных метрах (лог из Box2D
      подтвердил: target 28.1,9.4 м = 900,300 px, maxForce 562 Н, масса B 0.562 кг).
      Причину найти не удалось. Оставлять в движке сустав, который создаётся и молчит,
      нельзя — мёртвый путь убран целиком (C-ветка, сеттеры цели, биндинги, тест).
      
      Взамен — ЧЕСТНЫЙ инструмент того же назначения: $.world.tug(узел, x, y, opts).
      Это пружинный контроллер на скорости, а не телепорт: столкновение может перебить
      тягу.
      
      ПОПУТНО НАЙДЕН И ЗАКРЫТ РЕАЛЬНЫЙ ДЕФЕКТ ФИЗИКИ: Box2D засыпает тело на
      накопленном покое, и после сна setVelocity ПЕРЕСТАЁТ действовать — тело
      замирает там, где уснуло. Это бьёт по любому прямому управлению скоростью
      (перетаскивание, конвейер, телекинез). Добавлено engine.setSleeping(body, false)
      / engine.isSleeping(body) и r2d_physics_set_sleeping.
      
      ЧЕСТНО О НЕПРОВЕРЕННОМ: и у tug тяга не доводит тело до цели — оно доезжает
      примерно на 270 px из 400 и замирает. Причина не выяснена (управление сном
      добавлено, но не помогло), поэтому в тесте проверяется ровно работающее
      (тело сдвинулось к цели), а в world.md это написано прямо, без обещаний.
      
      Проверка: tests/agent/highlevel_joints_test.py (13 проверок), полный qjs,
      агентские тесты, doc_coverage и doc_claims зелёные.
      8d2162dd
    • Nikitos's avatar
      Суставы mouse и filter; pulley/gear невозможны — их нет в Box2D v3
      · 9c95fb9a
      Nikitos создал
      Продолжение разбора суставов. Обещание «восемь видов» оказалось устаревшим ещё
      сильнее, чем казалось: в b2JointType Box2D v3 остались только distance, filter,
      motor, mouse, prismatic, revolute, weld, wheel. Блока (pulley) и зубчатой
      передачи (gear) в движке НЕТ — это суставы Box2D v2. Обещать их нельзя ни в
      каком виде, поэтому в документации это сказано прямо.
      
      * physics: R2D_JOINT_MOUSE и R2D_JOINT_FILTER, r2d_physics_set/get_joint_target;
      * script.c: разбор 'mouse'/'filter', engine.setJointTarget(id, x, y) и
        engine.jointTarget(id);
      * $.world.jointTarget(id, x, y) — цель mouse-сустава;
      * сила mouse-сустава по умолчанию берётся из МАССЫ тела: значение Box2D по
        умолчанию (1 Н) не подняло бы и килограмма, и перетаскивание не работало бы
        даже теоретически;
      * НАЙДЕН ДЕФЕКТ: Box2D v3 не будит тела созданием сустава. Только что
        созданный сустав не действовал на уснувшее тело — теперь оба тела будятся
        при создании сустава (касается всех видов).
      
      ЧЕСТНО ПРО НЕПРОВЕРЕННОЕ: тяга mouse-сустава не работает — тело к цели не
      поехало ни в тесте, ни в отдельной пробе. Проверено, что параметры верные:
      тело A статическое, B динамическое, масса 0.56 кг, сила 500000 Н, цель задана.
      Причину найти не удалось, поэтому в тесте проверяется только создание и
      перестановка цели, а в world.md/API.md прямо написано «не полагайтесь» с
      альтернативой (castShape/bodyAt + applyImpulse).
      
      Проверка: tests/agent/highlevel_joints_test.py (13 проверок), полный набор qjs,
      агентских тестов, doc_coverage и doc_claims зелёный.
      9c95fb9a
    • Nikitos's avatar
      Суставы prismatic и wheel: два вида вместо молчаливой подмены на шарнир
      · a6b7715b
      Nikitos создал
      В docs/highlevel/world.md было написано, что поддержаны восемь видов суставов
      (revolute, distance, weld, prismatic, wheel, pulley, gear, mouse), а в коде было
      ТРИ. Хуже того, запрос 'prismatic' или 'wheel' не отказывал: движок молча брал
      revolute, и игра получала шарнир там, где просила направляющую. Это дефект не
      документации, а поведения — «неизвестное» не должно выполняться как «другое».
      
      * physics: R2D_JOINT_PRISMATIC и R2D_JOINT_WHEEL, ось сустава в мировых
        координатах (r2d_physics_set_joint_axis, по умолчанию (1,0), нулевая ось
        заменяется на X — Box2D требует единичный вектор);
      * script.c: разбор 'prismatic'/'wheel'/'revolute', ось из opts.axis;
        НЕИЗВЕСТНЫЙ тип теперь пишет предупреждение в журнал, а не подменяется молча;
      * $.world.joint(a, b, { type, axis, a: [...], b: [...], limit, motor }) —
        пределы для prismatic в метрах (переводит низкий уровень), мотор как сила.
      
      ЧЕСТНО ПРО ДОКУМЕНТАЦИЮ: обещание восьми видов было ложным. Приведено к факту —
      теперь в world.md, API.md и TASKS.md перечислены пять сделанных видов и прямо
      сказано, что pulley, gear и mouse не сделаны.
      
      Проверка (tests/agent/highlevel_joints_test.py, 12 проверок): все пять видов
      создаются и получают разные id, счётчик и уничтожение работают, а поведенчески
      проверено главное отличие направляющей — тело, которому задали скорость вбок,
      по оси Y смещается на 0.0 px (ось держит). Полный набор qjs, агентских тестов,
      doc_coverage и doc_claims зелёный.
      a6b7715b
    • Nikitos's avatar
      CCD: быстрые тела не проскакивают стены ($.world.bullet, тег <bullet>)
      · c683d4d2
      Nikitos создал
      Первый пункт P2-хвоста из §12 и реальный дефект из §1.3: тело 8 px при 1200 px/с
      за шаг 1/60 проходит около 20 px и пролетает тонкую стену между подшагами. До
      сих пор обходом был только ручной castShape в кадре.
      
      * физика: def.isBullet в R2DBodyDesc, r2d_physics_set_bullet/is_bullet;
      * engine.setBullet(id, on) / engine.isBullet(id), опция `bullet: true` в createBody;
      * высокоуровневое API: $.world.bullet(узел, on), $.world.isBullet(узел),
        .bullet(on) у узла; тег <bullet> получает CCD по умолчанию;
      * флаг хранится на узле (bullet_on), поэтому пересозданное тело (после смены
        размера) сохраняет настройку.
      
      ЧЕСТНО О ПРОВЕРКЕ. Проверено: флаг доходит до Box2D (isBullet → true, включение
      и выключение на ходу работают, цепочка возвращает обёртку). НЕ проверено
      поведенчески: воспроизвести туннелирование на стенде не удалось — обе пули,
      с CCD и без, останавливались у стены (x ≈ 495) и на 3, и на 12, и на 60 кадрах.
      Поэтому в tests/agent/highlevel_ccd_test.py (10 проверок) стоит ровно то, что
      доказано, а необходимый следующий шаг записан в тесте, world.md и TASKS §1.3:
      стена 1–2 px и подшаг мельче 1/60.
      
      Попутно найдено: $('<bullet>', { bullet: false }) работал не так, как ожидается,
      только из-за отсутствия умолчания у тега — теперь умолчание явное и проверено;
      и выяснилось, что обёртка НЕ форвардит поля узла (bullet_on читается только
      через nodes[0]) — это документировано в core.md §5.
      
      Проверка: полный набор qjs, bsp, агентских тестов (включая новый ccd),
      doc_coverage и doc_claims зелёный.
      c683d4d2
    • Nikitos's avatar
      docs: подсистема текста, биндинги шрифтов и поправки про ImGui
      · 47754cd7
      Nikitos создал
      * docs/highlevel/text.md — новый справочник: шрифты и автозагрузка, семейство на
        узле, где живёт текст, кегль и зум, измерение, атлас, ограничения;
      * API.md — drawText с семейством, углом и масштабом; measureText с семейством;
        loadFont/fontDefault/fontList/fontStats; убран рассказ про ImGui-очередь,
        которой больше нет;
      * HIGH_LEVEL_API.md — ссылка на text.md и метод .font();
      * font.md — ограничения больше не врут про «свои файлы шрифтов задать нельзя».
      47754cd7
    • Nikitos's avatar
      P0.4: события контакта не теряются на подшагах
      · 11cdf97d
      Nikitos создал
      Буфер контактов обнулялся в начале КАЖДОГО шага физики, а JS читает его один
      раз за кадр; за кадр шагов до пяти, поэтому выживали только события последнего
      шага — урон и смерть срабатывали через раз.
      
      Добавлен r2d_physics_begin_contacts(): обнуляет буфер и включает накопление на
      кадр. main.c зовёт его до цикла подшагов. Документация API поправлена.
      11cdf97d
  2. 06.10.2026 6 коммитов
  3. 05.10.2026 1 коммит
    • Nikitos's avatar
      Russiano2D 0.1.0 — первый публичный релиз
      · a6768866
      Nikitos создал
      Российский 2D-игровой движок: ядро на C11, игровая логика на JavaScript,
      единая точка входа — объект `$` в стиле jQuery.
      
      Стек: SDL3 (окно, ввод, SDL_GPU: Vulkan/Metal/DirectX 12), QuickJS-ng 0.10,
      Box2D v3.1, SDL3_mixer, RmlUi 6.3, Dear ImGui, свои 2D BSP-дерево и полигоны
      видимости, Material Design Icons, опционально libcurl для $.http.
      
      Что умеет:
      - высокоуровневое API `$`: создание узлов `$('<player>', {...})`, CSS-подобные
        селекторы, цепочки, события, твины;
      - подсистемы: анимация клипами и машина состояний ($.anim), TileMap со слоями,
        автотайлом, террейнами и Y-sort ($.tilemap), CPU-частицы ($.particles),
        навигация A* и navmesh ($.nav), prefab и сериализация сцен ($.prefab),
        аудио-шины и эффекты ($.audio), канвас-слои и параллакс ($.layers),
        UI-контролы с якорями и темами ($.ui), Tween в стиле Godot ($.tween),
        зоны enter/leave ($.triggers), локализация ($.i18n), пул объектов ($.pool),
        HTTP-запросы ($.http);
      - физика: формы тел (прямоугольник, круг, капсула, полигон), односторонние
        платформы, события контакта collide/separate/hit, суставы;
      - blend-режимы alpha/add/multiply/none на узле и на кадр;
      - сборка игры в один исполняемый файл с шифрованием груза (ChaCha20-Poly1305);
      - интеграция с ИИ-агентами: агентский режим (JSON по stdin/stdout), headless,
        детерминированный прогон --fixed-dt/--seed, eval/state/screenshot,
        виртуальный ввод с текстом, $.agent/$.test, Python-клиент и раннер тестов.
      
      Проверки: 24 агентских теста (tools/run_tests.py), 18 наборов юнит-тестов
      под qjs, отдельные C-тесты JSON и криптографии. CI: .gitlab-ci.yml собирает
      Linux (gcc и clang) и Windows (MSVC), упаковывает dist/ и выпускает релиз по
      тегу v*.
      
      Документация: docs/HIGH_LEVEL_API.md, docs/API.md, docs/AGENT_API.md,
      docs/BUILD.md, docs/RELEASING.md, docs/GAP_ANALYSIS.md, docs/tutorial-first-game.md
      и справочники подсистем в docs/highlevel/.
      
      Лицензия авторская: использовать, менять, распространять и продавать свободно;
      если выпустишь игру на движке — скажи спасибо автору, можно не вслух (LICENSE).
      
      Репозиторий: https://hub.mos.ru/dem4ev48/russiano2d
      a6768866