- 07.10.2026 21 коммит
-
-
Nikitos создал
Фазы 6-8: утверждения $.expect, реактивные запросы $.watch, DevTools $.devtools на RmlUi, engine.ui.loadMarkup v0.1.16
-
Nikitos создал
Запросы в радиусе и within(), команды query/inspect/profile, запись и воспроизведение ввода (--record/--replay), законы движка и правила агентов, сверка доков с кодом v0.1.15
-
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, быстрый набор — зелёные.
-
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, быстрый набор — зелёные. -
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, быстрый набор — зелёные. -
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, быстрый набор — зелёные. -
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, быстрый набор — зелёные. -
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, быстрый набор — зелёные. -
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, быстрый набор — зелёные. -
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, быстрый набор — зелёные.
-
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, быстрый набор — зелёные.
-
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, быстрый набор — зелёные.
-
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, быстрый набор — зелёные.
-
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, быстрый набор — зелёные.
-
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, быстрый набор — зелёные.
-
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 зелёные.
-
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 зелёный.
-
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 зелёный. -
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 зелёный. -
Nikitos создал
* docs/highlevel/text.md — новый справочник: шрифты и автозагрузка, семейство на узле, где живёт текст, кегль и зум, измерение, атлас, ограничения; * API.md — drawText с семейством, углом и масштабом; measureText с семейством; loadFont/fontDefault/fontList/fontStats; убран рассказ про ImGui-очередь, которой больше нет; * HIGH_LEVEL_API.md — ссылка на text.md и метод .font(); * font.md — ограничения больше не врут про «свои файлы шрифтов задать нельзя».
-
Nikitos создал
Буфер контактов обнулялся в начале КАЖДОГО шага физики, а JS читает его один раз за кадр; за кадр шагов до пяти, поэтому выживали только события последнего шага — урон и смерть срабатывали через раз. Добавлен r2d_physics_begin_contacts(): обнуляет буфер и включает накопление на кадр. main.c зовёт его до цикла подшагов. Документация API поправлена.
-
- 06.10.2026 6 коммитов
- 05.10.2026 1 коммит
-
-
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
-