Добавить общую систему весовых объектов

This commit is contained in:
2026-08-19 07:40:11 +03:00
parent 19bb64edfb
commit 259fcd316b
32 changed files with 1017 additions and 34 deletions
+2 -2
View File
@@ -59,7 +59,7 @@
{"_type":"issue","id":"goon-game-e09","title":"EPIC-09 Save \u0026 Progression","description":"Версионированные сохранения, unlock, challenges, collectibles и статистика. Разделы 20–22, 34.","acceptance_criteria":"Прогресс сохраняется между версиями и корректно мигрирует при обновлении.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:01Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:33Z","metadata":{"stages":"0,5,6","primary_stage":6},"labels":["epic","primary-stage-6","progression","save","stage-0","stage-5","stage-6"],"dependencies":[{"issue_id":"goon-game-e09","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:51Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e09","title":"EPIC-09 Save \u0026 Progression","description":"Версионированные сохранения, unlock, challenges, collectibles и статистика. Разделы 20–22, 34.","acceptance_criteria":"Прогресс сохраняется между версиями и корректно мигрирует при обновлении.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:01Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:33Z","metadata":{"stages":"0,5,6","primary_stage":6},"labels":["epic","primary-stage-6","progression","save","stage-0","stage-5","stage-6"],"dependencies":[{"issue_id":"goon-game-e09","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:51Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e08","title":"EPIC-08 UI/UX","description":"Android controls, HUD, меню, выбор уровней и доступность. Разделы 16–17, 31–33, 45.","acceptance_criteria":"Игра полностью понятна и управляема на поддерживаемых экранах без внешних объяснений.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:59Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:29Z","metadata":{"stages":"0,3,6","primary_stage":6},"labels":["epic","primary-stage-6","stage-0","stage-3","stage-6","ui","ux"],"dependencies":[{"issue_id":"goon-game-e08","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:50Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e08","title":"EPIC-08 UI/UX","description":"Android controls, HUD, меню, выбор уровней и доступность. Разделы 16–17, 31–33, 45.","acceptance_criteria":"Игра полностью понятна и управляема на поддерживаемых экранах без внешних объяснений.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:59Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:29Z","metadata":{"stages":"0,3,6","primary_stage":6},"labels":["epic","primary-stage-6","stage-0","stage-3","stage-6","ui","ux"],"dependencies":[{"issue_id":"goon-game-e08","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:50Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e06","title":"EPIC-06 Camera","description":"Следящая камера, look-ahead, damping, bounds и focus targets. Раздел 18.","acceptance_criteria":"Камера показывает будущий маршрут, не дёргается и соблюдает границы уровня.","notes":"Stage 5 camera acceptance complete: bounded follow/focus/shake, level bounds and reduced motion; both EPIC children are closed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:55Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:03Z","closed_at":"2026-08-18T23:03:03Z","close_reason":"Camera foundation and production polish acceptance are complete.","metadata":{"stages":"0,5","primary_stage":5},"labels":["camera","epic","primary-stage-5","stage-0","stage-5"],"dependencies":[{"issue_id":"goon-game-e06","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e06","title":"EPIC-06 Camera","description":"Следящая камера, look-ahead, damping, bounds и focus targets. Раздел 18.","acceptance_criteria":"Камера показывает будущий маршрут, не дёргается и соблюдает границы уровня.","notes":"Stage 5 camera acceptance complete: bounded follow/focus/shake, level bounds and reduced motion; both EPIC children are closed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:55Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:03Z","closed_at":"2026-08-18T23:03:03Z","close_reason":"Camera foundation and production polish acceptance are complete.","metadata":{"stages":"0,5","primary_stage":5},"labels":["camera","epic","primary-stage-5","stage-0","stage-5"],"dependencies":[{"issue_id":"goon-game-e06","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e05","title":"EPIC-05 Puzzle Objects","description":"Кнопки, двери, платформы, весовые объекты и событийные сигналы. Разделы 11–14, 37.","acceptance_criteria":"Механизмы не связаны жёсткими ссылками и корректно работают с живым персонажем и телами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:53Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:15Z","metadata":{"stages":"0,6","primary_stage":6},"labels":["epic","primary-stage-6","puzzle","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e05","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:47Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e05","title":"EPIC-05 Puzzle Objects","description":"Кнопки, двери, платформы, весовые объекты и событийные сигналы. Разделы 11–14, 37.","acceptance_criteria":"Механизмы не связаны жёсткими ссылками и корректно работают с живым персонажем и телами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:53Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T04:38:26Z","closed_at":"2026-08-19T04:38:26Z","close_reason":"All puzzle-object children are complete: baseline switches/doors/platforms, event-driven PuzzleSystem and shared weighted physical objects. Player, corpse and authored objects now use data-driven uncoupled contracts with full regression evidence.","metadata":{"stages":"0,6","primary_stage":6},"labels":["epic","primary-stage-6","puzzle","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e05","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:47Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e04","title":"EPIC-04 Hazards","description":"Каталог опасностей и универсальный контракт источника смерти. Разделы 5, 10, 38.","acceptance_criteria":"Опасности data-driven, задокументированы и проходят Android/QA проверки.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:51Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T02:28:53Z","closed_at":"2026-08-19T02:28:53Z","close_reason":"Both vertical-slice and production hazard catalogs are complete; all data-driven definitions, teaching levels and Android QA gates are green.","metadata":{"stages":"0,6","primary_stage":6},"labels":["epic","hazards","primary-stage-6","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e04","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:45Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e04","title":"EPIC-04 Hazards","description":"Каталог опасностей и универсальный контракт источника смерти. Разделы 5, 10, 38.","acceptance_criteria":"Опасности data-driven, задокументированы и проходят Android/QA проверки.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:51Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T02:28:53Z","closed_at":"2026-08-19T02:28:53Z","close_reason":"Both vertical-slice and production hazard catalogs are complete; all data-driven definitions, teaching levels and Android QA gates are green.","metadata":{"stages":"0,6","primary_stage":6},"labels":["epic","hazards","primary-stage-6","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e04","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:45Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e01","title":"EPIC-01 Character","description":"Персонаж, state machine, движение и platforming. Разделы 3, 17, 28, 36.","acceptance_criteria":"Персонаж соответствует утверждённому набору состояний и стабильно управляется на Android.","notes":"2026-08-19: baseline controller and production state-machine children are closed; exact Android 16 36/36 confirms stable controls and routes.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:45Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T00:19:58Z","closed_at":"2026-08-19T00:19:58Z","close_reason":"Character controller and complete production animation-state contract are implemented and Android-verified.","metadata":{"stages":"0,6","primary_stage":6},"labels":["character","epic","primary-stage-6","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e01","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:42Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e01","title":"EPIC-01 Character","description":"Персонаж, state machine, движение и platforming. Разделы 3, 17, 28, 36.","acceptance_criteria":"Персонаж соответствует утверждённому набору состояний и стабильно управляется на Android.","notes":"2026-08-19: baseline controller and production state-machine children are closed; exact Android 16 36/36 confirms stable controls and routes.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:45Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T00:19:58Z","closed_at":"2026-08-19T00:19:58Z","close_reason":"Character controller and complete production animation-state contract are implemented and Android-verified.","metadata":{"stages":"0,6","primary_stage":6},"labels":["character","epic","primary-stage-6","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e01","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:42Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-milestone-alpha","title":"Milestone: Alpha","description":"Вся игра проходима от первого меню до финала; контент существует.","acceptance_criteria":"End-to-end прохождение завершено на release-like build.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:05Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:39Z","metadata":{"stages":8,"primary_stage":8},"labels":["alpha","milestone","primary-stage-8","stage-8"],"dependencies":[{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-crash-reporting","type":"blocks","created_at":"2026-08-18T00:44:28Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-device-matrix","type":"blocks","created_at":"2026-08-18T00:44:27Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:57Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-milestone-production","type":"blocks","created_at":"2026-08-18T00:44:26Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"goon-game-milestone-alpha","title":"Milestone: Alpha","description":"Вся игра проходима от первого меню до финала; контент существует.","acceptance_criteria":"End-to-end прохождение завершено на release-like build.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:05Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:39Z","metadata":{"stages":8,"primary_stage":8},"labels":["alpha","milestone","primary-stage-8","stage-8"],"dependencies":[{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-crash-reporting","type":"blocks","created_at":"2026-08-18T00:44:28Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-device-matrix","type":"blocks","created_at":"2026-08-18T00:44:27Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:57Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-milestone-production","type":"blocks","created_at":"2026-08-18T00:44:26Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0}
@@ -68,7 +68,7 @@
{"_type":"issue","id":"goon-game-modular-characters","title":"Модульные персонажи и косметика","description":"Body/helmet/face/weapon/cape/accessory, случайные имена и 30+ косметических элементов.","acceptance_criteria":"Новые жертвы визуально различаются, комбинации не ломают анимации.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2880,"created_at":"2026-08-17T21:40:21Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","metadata":{"stages":6,"primary_stage":6},"labels":["art","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-modular-characters","depends_on_id":"goon-game-e11","type":"parent-child","created_at":"2026-08-18T00:43:33Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-modular-characters","title":"Модульные персонажи и косметика","description":"Body/helmet/face/weapon/cape/accessory, случайные имена и 30+ косметических элементов.","acceptance_criteria":"Новые жертвы визуально различаются, комбинации не ломают анимации.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2880,"created_at":"2026-08-17T21:40:21Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","metadata":{"stages":6,"primary_stage":6},"labels":["art","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-modular-characters","depends_on_id":"goon-game-e11","type":"parent-child","created_at":"2026-08-18T00:43:33Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-challenges","title":"Challenges, collectibles и статистика","description":"Реализовать casualty/time/collectible цели и отображение трёх звёзд.","acceptance_criteria":"Расчёт покрыт unit-тестами и сохраняется по LevelID.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:13Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","progression","stage-6"],"dependencies":[{"issue_id":"goon-game-challenges","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:29Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-challenges","title":"Challenges, collectibles и статистика","description":"Реализовать casualty/time/collectible цели и отображение трёх звёзд.","acceptance_criteria":"Расчёт покрыт unit-тестами и сохраняется по LevelID.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:40:13Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","progression","stage-6"],"dependencies":[{"issue_id":"goon-game-challenges","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:29Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-content-worlds","title":"Спроектировать и произвести 4 мира / 55–70 уровней","description":"Content matrix четырёх миров, challenge-уровней и финальных испытаний по разделам 2 и 23.","acceptance_criteria":"Все уровни проходят content pipeline и Definition of Done.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","metadata":{"stages":6,"primary_stage":6},"labels":["future","levels","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:41:41Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"goon-game-content-worlds","title":"Спроектировать и произвести 4 мира / 55–70 уровней","description":"Content matrix четырёх миров, challenge-уровней и финальных испытаний по разделам 2 и 23.","acceptance_criteria":"Все уровни проходят content pipeline и Definition of Done.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","metadata":{"stages":6,"primary_stage":6},"labels":["future","levels","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T00:43:21Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-content-worlds","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:41:41Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-weighted-objects","title":"Добавить общий IWeightedObject и физические объекты","description":"Добавить box, rock, ball, ice block и counterweight поверх единого контракта массы.","acceptance_criteria":"Pressure plate одинаково работает с персонажем, телом и объектами.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:43Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:26Z","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-weighted-objects","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:14Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-weighted-objects","title":"Добавить общий IWeightedObject и физические объекты","description":"Добавить box, rock, ball, ice block и counterweight поверх единого контракта массы.","acceptance_criteria":"Pressure plate одинаково работает с персонажем, телом и объектами.","notes":"2026-08-19: claimed after corpse-state slice 19bb64e passed 102 JVM and exact Android 46/46. TDD scope: public IWeightedObject mass contract, JSON Box/Rock/Ball/IceBlock/Counterweight definitions, deterministic runtime/save/render/automation, pressure parity and one teaching level with solution bot.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:43Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T04:38:25Z","started_at":"2026-08-19T03:35:44Z","closed_at":"2026-08-19T04:38:25Z","close_reason":"Shared pressure-mass contract, five authored physical-object types, deterministic runtime/save v4/render/export, level 17 and production-input route accepted. Clean 108 app JVM + 2 format JVM; focused 8/8; exact unfiltered Android 16 matrix 48/48 in 2223.695s.","metadata":{"stages":6,"primary_stage":6},"labels":["primary-stage-6","production","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-weighted-objects","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:14Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-hazards-full","title":"Дополнить production-каталог опасностей","description":"Добавить crusher, fire, projectile, explosion и data-driven настройки импульса/звука/визуала.","acceptance_criteria":"Каждая опасность имеет prefab/data definition, тесты и минимум один учебный уровень.","notes":"2026-08-19: claimed after the three Stage 6 foundation slices. Will proceed test-first per hazard with data definition, deterministic behavior, teaching content, solution graph and production-input route; crusher/fire precede the dependent crushed/burned corpse states.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2400,"created_at":"2026-08-17T21:39:37Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T02:28:37Z","started_at":"2026-08-19T01:07:11Z","closed_at":"2026-08-19T02:28:37Z","close_reason":"Crusher, fire, projectile and cyclic explosion are data-driven, validated and shipped with teaching levels 11-14, solution graphs and 3x production-input routes; clean 99 JVM/lint/APK and unfiltered Android 43/43 green.","metadata":{"stages":6,"primary_stage":6},"labels":["hazards","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-hazards-full","depends_on_id":"goon-game-e04","type":"parent-child","created_at":"2026-08-18T00:43:10Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"goon-game-hazards-full","title":"Дополнить production-каталог опасностей","description":"Добавить crusher, fire, projectile, explosion и data-driven настройки импульса/звука/визуала.","acceptance_criteria":"Каждая опасность имеет prefab/data definition, тесты и минимум один учебный уровень.","notes":"2026-08-19: claimed after the three Stage 6 foundation slices. Will proceed test-first per hazard with data definition, deterministic behavior, teaching content, solution graph and production-input route; crusher/fire precede the dependent crushed/burned corpse states.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2400,"created_at":"2026-08-17T21:39:37Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T02:28:37Z","started_at":"2026-08-19T01:07:11Z","closed_at":"2026-08-19T02:28:37Z","close_reason":"Crusher, fire, projectile and cyclic explosion are data-driven, validated and shipped with teaching levels 11-14, solution graphs and 3x production-input routes; clean 99 JVM/lint/APK and unfiltered Android 43/43 green.","metadata":{"stages":6,"primary_stage":6},"labels":["hazards","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-hazards-full","depends_on_id":"goon-game-e04","type":"parent-child","created_at":"2026-08-18T00:43:10Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-corpse-states","title":"Расширить состояния тел","description":"Добавить crushed, burned и остальные утверждённые состояния поверх normal/pinned/frozen/conductive.","acceptance_criteria":"Каждое состояние используется минимум одной уникальной головоломкой и проходит DoD механики.","notes":"2026-08-19: claimed after production hazards dc2ebcb passed 99 JVM and Android 43/43. Will add explicit crushed/burned/shattered and derived corpse behavior test-first, then one teaching route/solution graph per new gameplay-relevant state and full regression.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T03:32:49Z","started_at":"2026-08-19T02:31:25Z","closed_at":"2026-08-19T03:32:49Z","close_reason":"GDD corpse states complete: existing normal/pinned/frozen/conductive routes plus new burned/crushed contracts, save v3 migration, levels 15-16 and bots. Clean 102 JVM; exact Android 46/46 in 1842.248s. IsDestroyed is a lifecycle field; Shattered is not an approved GDD state.","metadata":{"stages":6,"primary_stage":6},"labels":["corpse","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:05Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-hazards-full","type":"blocks","created_at":"2026-08-19T04:07:09Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-corpse-states","title":"Расширить состояния тел","description":"Добавить crushed, burned и остальные утверждённые состояния поверх normal/pinned/frozen/conductive.","acceptance_criteria":"Каждое состояние используется минимум одной уникальной головоломкой и проходит DoD механики.","notes":"2026-08-19: claimed after production hazards dc2ebcb passed 99 JVM and Android 43/43. Will add explicit crushed/burned/shattered and derived corpse behavior test-first, then one teaching route/solution graph per new gameplay-relevant state and full regression.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":2,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T03:32:49Z","started_at":"2026-08-19T02:31:25Z","closed_at":"2026-08-19T03:32:49Z","close_reason":"GDD corpse states complete: existing normal/pinned/frozen/conductive routes plus new burned/crushed contracts, save v3 migration, levels 15-16 and bots. Clean 102 JVM; exact Android 46/46 in 1842.248s. IsDestroyed is a lifecycle field; Shattered is not an approved GDD state.","metadata":{"stages":6,"primary_stage":6},"labels":["corpse","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:05Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-corpse-states","depends_on_id":"goon-game-hazards-full","type":"blocks","created_at":"2026-08-19T04:07:09Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e14","title":"EPIC-14 Analytics","description":"События прохождения, позиции смертей и crash reporting без лишних персональных данных. Раздел 47.","acceptance_criteria":"События и ошибки проверены в тестовом канале, схема версионирована.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:12Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:57Z","metadata":{"stages":"7,8,9,10","primary_stage":7},"labels":["analytics","epic","primary-stage-7","stage-10","stage-7","stage-8","stage-9"],"dependencies":[{"issue_id":"goon-game-e14","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:57Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e14","title":"EPIC-14 Analytics","description":"События прохождения, позиции смертей и crash reporting без лишних персональных данных. Раздел 47.","acceptance_criteria":"События и ошибки проверены в тестовом канале, схема версионирована.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:12Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:57Z","metadata":{"stages":"7,8,9,10","primary_stage":7},"labels":["analytics","epic","primary-stage-7","stage-10","stage-7","stage-8","stage-9"],"dependencies":[{"issue_id":"goon-game-e14","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:57Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
+10 -5
View File
@@ -12,13 +12,14 @@ Project documents:
- [`docs/STAGE5_PERFORMANCE_BUDGETS.md`](docs/STAGE5_PERFORMANCE_BUDGETS.md) — physics, replay, frame, memory, and complexity ceilings; - [`docs/STAGE5_PERFORMANCE_BUDGETS.md`](docs/STAGE5_PERFORMANCE_BUDGETS.md) — physics, replay, frame, memory, and complexity ceilings;
- [`docs/STAGE5_QA_REPORT.md`](docs/STAGE5_QA_REPORT.md) — exact Vertical Slice candidate evidence; - [`docs/STAGE5_QA_REPORT.md`](docs/STAGE5_QA_REPORT.md) — exact Vertical Slice candidate evidence;
- [`docs/STAGE6_CORPSE_STATES.md`](docs/STAGE6_CORPSE_STATES.md) — burned/crushed body contracts and acceptance evidence; - [`docs/STAGE6_CORPSE_STATES.md`](docs/STAGE6_CORPSE_STATES.md) — burned/crushed body contracts and acceptance evidence;
- [`docs/STAGE6_WEIGHTED_OBJECTS.md`](docs/STAGE6_WEIGHTED_OBJECTS.md) — shared mass contract, physical objects, and level 17 evidence;
- [`docs/STAGE5_HUMAN_PLAYTEST.md`](docs/STAGE5_HUMAN_PLAYTEST.md) — required external acceptance protocol; - [`docs/STAGE5_HUMAN_PLAYTEST.md`](docs/STAGE5_HUMAN_PLAYTEST.md) — required external acceptance protocol;
- [`docs/STAGE_REGISTRY.md`](docs/STAGE_REGISTRY.md) — mandatory Stage 0–10 ownership for every Bead; - [`docs/STAGE_REGISTRY.md`](docs/STAGE_REGISTRY.md) — mandatory Stage 0–10 ownership for every Bead;
- [`docs/BEADS_WORKFLOW.md`](docs/BEADS_WORKFLOW.md) — local backlog structure and daily Beads commands. - [`docs/BEADS_WORKFLOW.md`](docs/BEADS_WORKFLOW.md) — local backlog structure and daily Beads commands.
## Playable content ## Playable content
The APK contains sixteen sequential levels: The APK contains seventeen sequential levels:
1. movement and jumping; 1. movement and jumping;
2. bodies covering a spike lane; 2. bodies covering a spike lane;
@@ -35,7 +36,8 @@ The APK contains sixteen sequential levels:
13. two state-driven projectile crossings; 13. two state-driven projectile crossings;
14. explosion delivery to a remote pressure door; 14. explosion delivery to a remote pressure door;
15. a crushed body whose low collider becomes a required step; 15. a crushed body whose low collider becomes a required step;
16. a burned body that stays solid but no longer powers a pressure bridge. 16. a burned body that stays solid but no longer powers a pressure bridge;
17. five physical object types sharing a mass-nine pressure gate.
Death does not reset the level. A new volunteer respawns after a short delay while retained bodies stay in the simulation. The restart button deliberately clears the puzzle state. Progress, best time, and lowest volunteer count are stored locally. Death does not reset the level. A new volunteer respawns after a short delay while retained bodies stay in the simulation. The restart button deliberately clears the puzzle state. Progress, best time, and lowest volunteer count are stored locally.
@@ -79,7 +81,7 @@ Previews are written to `level-format/build/level-previews`. Invalid geometry,
versions, moving-platform destinations, switch parameters, or signal references versions, moving-platform destinations, switch parameters, or signal references
fail the build with a JSON path diagnostic. fail the build with a JSON path diagnostic.
All sixteen levels have device-level acceptance bots. They drive the production All seventeen levels have device-level acceptance bots. They drive the production
`GameView` through the same movement and jump inputs as the touch controls and `GameView` through the same movement and jump inputs as the touch controls and
run each intended solution three times. Level 8 also has a dedicated recovery run each intended solution three times. Level 8 also has a dedicated recovery
scenario for an expired timer on the far side of the pit. Stage 2 additionally scenario for an expired timer on the far side of the pit. Stage 2 additionally
@@ -117,11 +119,14 @@ deterministic character state machine, a bounded `CorpseManager`, and an
event-driven `PuzzleSystem` with explicit signal and door transitions. The first event-driven `PuzzleSystem` with explicit signal and door transitions. The first
mechanics slice adds data-driven crusher, fire, projectile and explosion hazards, mechanics slice adds data-driven crusher, fire, projectile and explosion hazards,
teaching levels 11–14, dynamic level pagination and production-input routes. teaching levels 11–14, dynamic level pagination and production-input routes.
Burned/crushed corpse states are taught in levels 15–16, and level 17 adds five
authored physical-object types over one shared pressure-mass contract.
Scope and final evidence are in `docs/STAGE6_CHARACTER_STATES.md`, Scope and final evidence are in `docs/STAGE6_CHARACTER_STATES.md`,
`docs/STAGE6_CORPSE_MANAGER.md`, and `docs/STAGE6_PUZZLE_SYSTEM.md`; the latest `docs/STAGE6_CORPSE_MANAGER.md`, and `docs/STAGE6_PUZZLE_SYSTEM.md`; the latest
hazard evidence is in `docs/STAGE6_PRODUCTION_HAZARDS.md`, and the burned/crushed hazard evidence is in `docs/STAGE6_PRODUCTION_HAZARDS.md`, and the burned/crushed
contract is in `docs/STAGE6_CORPSE_STATES.md`. The latest exact gate is contract is in `docs/STAGE6_CORPSE_STATES.md`. Weighted-object evidence is in
`102 JVM / 46 Android` on the sixteen-level candidate. `docs/STAGE6_WEIGHTED_OBJECTS.md`. The latest exact gate is `108 app JVM + 2
content-tool JVM / 48 Android` on the seventeen-level candidate.
The debug APK is written to `app/build/outputs/apk/debug/app-debug.apk`. It is signed with Android's standard debug key and can be installed with: The debug APK is written to `app/build/outputs/apk/debug/app-debug.apk`. It is signed with Android's standard debug key and can be installed with:
@@ -37,6 +37,7 @@ public final class LevelPaginationTest {
assertEquals(1, view.levelPageIndexForAutomation()); assertEquals(1, view.levelPageIndexForAutomation());
assertTrue(visible.contains("Steam Charge")); assertTrue(visible.contains("Steam Charge"));
assertTrue(visible.contains("Ashen Counterweight")); assertTrue(visible.contains("Ashen Counterweight"));
assertTrue(visible.contains("The Whole Weight"));
assertFalse(visible.contains("The Volunteer")); assertFalse(visible.contains("The Volunteer"));
assertTrue(visible.contains("Page 2 of 2")); assertTrue(visible.contains("Page 2 of 2"));
}); });
@@ -0,0 +1,42 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public final class WeightedObjectIntegrationTest {
@Test
public void productionInputPushesSharedMassOntoPlateAndSaveCapturesEveryObject() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
view.enableDeterministicAutomationClock();
view.startLevelForAutomation(17);
GameView.AutomationSnapshot initial = view.automationSnapshot();
assertEquals(5, initial.physicalObjectCount());
assertEquals("COUNTERWEIGHT", initial.firstPhysicalObjectType());
assertEquals(3, initial.firstPhysicalObjectMass());
assertFalse(initial.allDoorsOpen());
view.setAutomationInput(false, true, false);
view.advanceDeterministicAutomation(3_000L);
GameView.AutomationSnapshot pushed = view.automationSnapshot();
assertTrue(pushed.firstPhysicalObjectX() >= 920f);
assertTrue(pushed.allDoorsOpen());
GameSessionSnapshot saved = view.sessionSnapshotForAutomation();
assertEquals(5, saved.objects().size());
assertEquals(LevelDefinition.PhysicalObjectType.COUNTERWEIGHT,
saved.objects().get(0).type());
});
}
}
}
@@ -0,0 +1,69 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.app.Instrumentation;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public final class WeightedObjectPlaythroughTest {
@Test
public void allFiveObjectTypesOpenTheMassNineGateThreeTimes() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
Driver driver = Driver.from(activity);
for (int run = 1; run <= 3; run++) {
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
driver, WeightedObjectScenarios.levelSeventeen(), 50_000L);
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
assertEquals("weighted route is deathless", 0, result.deaths());
assertEquals("weighted route does not create bodies", 0, result.corpseCount());
}
}
}
private static final class Driver implements LevelPlaytestBot.Driver {
private final Instrumentation instrumentation;
private final GameView view;
private Driver(Instrumentation instrumentation, GameView view) {
this.instrumentation = instrumentation;
this.view = view;
}
static Driver from(ActivityScenario<MainActivity> activity) {
GameView[] target = new GameView[1];
activity.onActivity(current -> target[0] = current.gameViewForAutomation());
return new Driver(InstrumentationRegistry.getInstrumentation(), target[0]);
}
@Override public void startLevel(int oneBasedLevelNumber) {
instrumentation.runOnMainSync(() -> view.startLevelForAutomation(oneBasedLevelNumber));
}
@Override public void setInput(boolean left, boolean right, boolean jump) {
instrumentation.runOnMainSync(() -> view.setAutomationInput(left, right, jump));
}
@Override public GameView.AutomationSnapshot snapshot() {
GameView.AutomationSnapshot[] snapshot = new GameView.AutomationSnapshot[1];
instrumentation.runOnMainSync(() -> snapshot[0] = view.automationSnapshot());
return snapshot[0];
}
@Override public void waitForNextDecision(long millis) {
try {
Thread.sleep(Math.max(32L, millis));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new AssertionError("Weighted-object bot interrupted", interrupted);
}
}
}
}
@@ -0,0 +1,52 @@
package com.projectsacrifice.game;
/** Production-input route for the weighted-object teaching puzzle. */
final class WeightedObjectScenarios {
private WeightedObjectScenarios() {}
static LevelPlaytestBot.Scenario levelSeventeen() {
return LevelPlaytestBot.Scenario.builder(17, "Level 17 - shared mass gate")
.step("Observe all authored weight types", 1_000L,
LevelPlaytestBot.Input.none(),
snapshot -> snapshot.physicalObjectCount() == 5
&& snapshot.firstPhysicalObjectMass() == 3
&& "COUNTERWEIGHT".equals(snapshot.firstPhysicalObjectType())
&& !snapshot.allDoorsOpen())
.step("Approach the counterweight", 2_000L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(0,
LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
.step("Push the missing mass onto the plate", 3_000L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(0,
snapshot -> snapshot.playerAlive()
&& snapshot.firstPhysicalObjectX() >= 920f
&& snapshot.allDoorsOpen()))
.step("Release input while object mass holds the gate", 1_000L,
LevelPlaytestBot.Input.none(),
snapshot -> snapshot.allDoorsOpen()
&& snapshot.firstPhysicalObjectX() >= 920f)
.step("Take off over the counterweight", 1_000L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(0,
snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround()
&& snapshot.playerY() <= 700f))
.step("Land beyond the counterweight", 2_500L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(0,
snapshot -> snapshot.playerAlive()
&& snapshot.playerOnGround()
&& snapshot.playerX() >= 1_080f))
.step("Jump over the weighted row", 2_500L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(0,
snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 1_650f))
.step("Pass the mass-nine door and reach the relic", 5_000L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(0,
GameView.AutomationSnapshot::completed))
.build();
}
}
@@ -14,7 +14,7 @@ public final class CorpseManager {
CRUSHED CRUSHED
} }
public static final class Body { public static final class Body implements IWeightedObject {
final long id; final long id;
final String deathType; final String deathType;
float x; float x;
@@ -54,6 +54,11 @@ public final class CorpseManager {
public boolean activatesPressure() { return !burned; } public boolean activatesPressure() { return !burned; }
public boolean grounded() { return grounded; } public boolean grounded() { return grounded; }
public int supportIndex() { return supportIndex; } public int supportIndex() { return supportIndex; }
@Override public float weightedX() { return x; }
@Override public float weightedY() { return y; }
@Override public float weightedWidth() { return width; }
@Override public float weightedHeight() { return height; }
@Override public int pressureMass() { return 1; }
public Body position(float x, float y) { public Body position(float x, float y) {
this.x = x; this.x = x;
@@ -92,6 +92,41 @@ public final class GameSessionSnapshot {
public String deathType() { return deathType; } public String deathType() { return deathType; }
} }
public static final class ObjectData {
private final LevelDefinition.PhysicalObjectType type;
private final float x, y, width, height, velocityX, velocityY;
private final int mass;
private final boolean grounded;
private final int supportIndex;
public ObjectData(LevelDefinition.PhysicalObjectType type,
float x, float y, float width, float height,
float velocityX, float velocityY, int mass,
boolean grounded, int supportIndex) {
this.type = type;
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.velocityX = velocityX;
this.velocityY = velocityY;
this.mass = mass;
this.grounded = grounded;
this.supportIndex = supportIndex;
}
public LevelDefinition.PhysicalObjectType type() { return type; }
public float x() { return x; }
public float y() { return y; }
public float width() { return width; }
public float height() { return height; }
public float velocityX() { return velocityX; }
public float velocityY() { return velocityY; }
public int mass() { return mass; }
public boolean grounded() { return grounded; }
public int supportIndex() { return supportIndex; }
}
public static final class Builder { public static final class Builder {
private int saveVersion = VersionedSessionCodec.CURRENT_VERSION; private int saveVersion = VersionedSessionCodec.CURRENT_VERSION;
private final int levelNumber; private final int levelNumber;
@@ -101,6 +136,7 @@ public final class GameSessionSnapshot {
private PlayerData player = new PlayerData( private PlayerData player = new PlayerData(
0f, 0f, 0f, 0f, true, false, 0f, 0f, -1, 1); 0f, 0f, 0f, 0f, true, false, 0f, 0f, -1, 1);
private final ArrayList<BodyData> bodies = new ArrayList<>(); private final ArrayList<BodyData> bodies = new ArrayList<>();
private final ArrayList<ObjectData> objects = new ArrayList<>();
private final ArrayList<Float> platformMotionTimes = new ArrayList<>(); private final ArrayList<Float> platformMotionTimes = new ArrayList<>();
private final LinkedHashMap<String, Boolean> toggleSignals = new LinkedHashMap<>(); private final LinkedHashMap<String, Boolean> toggleSignals = new LinkedHashMap<>();
private final LinkedHashMap<String, Float> timedSignals = new LinkedHashMap<>(); private final LinkedHashMap<String, Float> timedSignals = new LinkedHashMap<>();
@@ -117,6 +153,7 @@ public final class GameSessionSnapshot {
public Builder respawnSeconds(float value) { respawnSeconds = value; return this; } public Builder respawnSeconds(float value) { respawnSeconds = value; return this; }
public Builder player(PlayerData value) { player = value; return this; } public Builder player(PlayerData value) { player = value; return this; }
public Builder addBody(BodyData value) { bodies.add(value); return this; } public Builder addBody(BodyData value) { bodies.add(value); return this; }
public Builder addObject(ObjectData value) { objects.add(value); return this; }
public Builder platformMotionTimes(List<Float> values) { public Builder platformMotionTimes(List<Float> values) {
platformMotionTimes.clear(); platformMotionTimes.clear();
if (values != null) platformMotionTimes.addAll(values); if (values != null) platformMotionTimes.addAll(values);
@@ -144,6 +181,7 @@ public final class GameSessionSnapshot {
private final float respawnSeconds; private final float respawnSeconds;
private final PlayerData player; private final PlayerData player;
private final List<BodyData> bodies; private final List<BodyData> bodies;
private final List<ObjectData> objects;
private final List<Float> platformMotionTimes; private final List<Float> platformMotionTimes;
private final Map<String, Boolean> toggleSignals; private final Map<String, Boolean> toggleSignals;
private final Map<String, Float> timedSignals; private final Map<String, Float> timedSignals;
@@ -157,6 +195,7 @@ public final class GameSessionSnapshot {
respawnSeconds = builder.respawnSeconds; respawnSeconds = builder.respawnSeconds;
player = builder.player; player = builder.player;
bodies = Collections.unmodifiableList(new ArrayList<>(builder.bodies)); bodies = Collections.unmodifiableList(new ArrayList<>(builder.bodies));
objects = Collections.unmodifiableList(new ArrayList<>(builder.objects));
platformMotionTimes = Collections.unmodifiableList( platformMotionTimes = Collections.unmodifiableList(
new ArrayList<>(builder.platformMotionTimes)); new ArrayList<>(builder.platformMotionTimes));
toggleSignals = Collections.unmodifiableMap( toggleSignals = Collections.unmodifiableMap(
@@ -174,6 +213,7 @@ public final class GameSessionSnapshot {
public float respawnSeconds() { return respawnSeconds; } public float respawnSeconds() { return respawnSeconds; }
public PlayerData player() { return player; } public PlayerData player() { return player; }
public List<BodyData> bodies() { return bodies; } public List<BodyData> bodies() { return bodies; }
public List<ObjectData> objects() { return objects; }
public List<Float> platformMotionTimes() { return platformMotionTimes; } public List<Float> platformMotionTimes() { return platformMotionTimes; }
public Map<String, Boolean> toggleSignals() { return toggleSignals; } public Map<String, Boolean> toggleSignals() { return toggleSignals; }
public Map<String, Float> timedSignals() { return timedSignals; } public Map<String, Float> timedSignals() { return timedSignals; }
@@ -80,6 +80,11 @@ public final class GameView extends View implements Runnable {
private final boolean firstCorpseActivatesPressure; private final boolean firstCorpseActivatesPressure;
private final float firstCorpseWidth; private final float firstCorpseWidth;
private final float firstCorpseHeight; private final float firstCorpseHeight;
private int physicalObjectCount;
private float firstPhysicalObjectX = Float.NaN;
private float firstPhysicalObjectY = Float.NaN;
private int firstPhysicalObjectMass;
private String firstPhysicalObjectType = "";
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen, AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
boolean visiblyPaused, boolean inputActive, int levelNumber, boolean visiblyPaused, boolean inputActive, int levelNumber,
@@ -224,6 +229,40 @@ public final class GameView extends View implements Runnable {
this.firstCorpseHeight = firstCorpseHeight; this.firstCorpseHeight = firstCorpseHeight;
} }
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
boolean visiblyPaused, boolean inputActive, int levelNumber,
long elapsedMilliseconds,
float playerX, float playerY, int deaths, int corpseCount,
float firstMovingPlatformX, float firstMovingPlatformY,
boolean playerOnMovingPlatform,
boolean firstMovingPlatformPowered,
boolean firstMovingPlatformMovingRight,
boolean firstMovingPlatformMovingDown, float firstCorpseX,
boolean playerOnGround, float playerVelocityX,
float playerVelocityY, boolean firstHazardActive,
float firstHazardX, float firstHazardY,
boolean firstCorpseBurned, boolean firstCorpseCrushed,
boolean firstCorpseActivatesPressure,
float firstCorpseWidth, float firstCorpseHeight,
int physicalObjectCount, float firstPhysicalObjectX,
float firstPhysicalObjectY, int firstPhysicalObjectMass,
String firstPhysicalObjectType) {
this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber,
elapsedMilliseconds, playerX, playerY, deaths, corpseCount,
firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform,
firstMovingPlatformPowered, firstMovingPlatformMovingRight,
firstMovingPlatformMovingDown, firstCorpseX, playerOnGround,
playerVelocityX, playerVelocityY, firstHazardActive, firstHazardX,
firstHazardY, firstCorpseBurned, firstCorpseCrushed,
firstCorpseActivatesPressure, firstCorpseWidth, firstCorpseHeight);
this.physicalObjectCount = physicalObjectCount;
this.firstPhysicalObjectX = firstPhysicalObjectX;
this.firstPhysicalObjectY = firstPhysicalObjectY;
this.firstPhysicalObjectMass = firstPhysicalObjectMass;
this.firstPhysicalObjectType = firstPhysicalObjectType == null
? "" : firstPhysicalObjectType;
}
public boolean completed() { return completed; } public boolean completed() { return completed; }
public boolean playerAlive() { return playerAlive; } public boolean playerAlive() { return playerAlive; }
public boolean allDoorsOpen() { return allDoorsOpen; } public boolean allDoorsOpen() { return allDoorsOpen; }
@@ -255,9 +294,14 @@ public final class GameView extends View implements Runnable {
} }
public float firstCorpseWidth() { return firstCorpseWidth; } public float firstCorpseWidth() { return firstCorpseWidth; }
public float firstCorpseHeight() { return firstCorpseHeight; } public float firstCorpseHeight() { return firstCorpseHeight; }
public int physicalObjectCount() { return physicalObjectCount; }
public float firstPhysicalObjectX() { return firstPhysicalObjectX; }
public float firstPhysicalObjectY() { return firstPhysicalObjectY; }
public int firstPhysicalObjectMass() { return firstPhysicalObjectMass; }
public String firstPhysicalObjectType() { return firstPhysicalObjectType; }
} }
private static final class Player { private static final class Player implements IWeightedObject {
float x, y, vx, vy; float x, y, vx, vy;
boolean alive; boolean alive;
boolean onGround; boolean onGround;
@@ -265,6 +309,13 @@ public final class GameView extends View implements Runnable {
float jumpBuffer; float jumpBuffer;
int supportIndex = -1; int supportIndex = -1;
int facing = 1; int facing = 1;
@Override public float weightedX() { return x; }
@Override public float weightedY() { return y; }
@Override public float weightedWidth() { return PLAYER_WIDTH; }
@Override public float weightedHeight() { return PLAYER_HEIGHT; }
@Override public int pressureMass() { return 1; }
@Override public boolean activatesPressure() { return alive; }
} }
private static final class PlatformState { private static final class PlatformState {
@@ -302,6 +353,7 @@ public final class GameView extends View implements Runnable {
new CharacterStateMachine(PHYSICS); new CharacterStateMachine(PHYSICS);
private final CorpseManager corpseManager = new CorpseManager(MAX_CORPSES); private final CorpseManager corpseManager = new CorpseManager(MAX_CORPSES);
private final List<Body> corpses = corpseManager.bodies(); private final List<Body> corpses = corpseManager.bodies();
private final ArrayList<PhysicalObjectState> physicalObjects = new ArrayList<>();
private final ArrayList<PlatformState> platformStates = new ArrayList<>(); private final ArrayList<PlatformState> platformStates = new ArrayList<>();
private final ArrayList<HazardRuntime> hazardRuntimes = new ArrayList<>(); private final ArrayList<HazardRuntime> hazardRuntimes = new ArrayList<>();
private final Map<String, Boolean> previousSwitchContact = new HashMap<>(); private final Map<String, Boolean> previousSwitchContact = new HashMap<>();
@@ -470,6 +522,7 @@ public final class GameView extends View implements Runnable {
pointers.clear(); pointers.clear();
deriveControls(); deriveControls();
corpseManager.reset(); corpseManager.reset();
physicalObjects.clear();
platformStates.clear(); platformStates.clear();
hazardRuntimes.clear(); hazardRuntimes.clear();
previousSwitchContact.clear(); previousSwitchContact.clear();
@@ -478,6 +531,9 @@ public final class GameView extends View implements Runnable {
configureAccessibilitySignals(); configureAccessibilitySignals();
invalidateHudTextCache(); invalidateHudTextCache();
for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item)); for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item));
for (LevelDefinition.PhysicalObject item : level.objects) {
physicalObjects.add(new PhysicalObjectState(item));
}
for (LevelDefinition.Hazard item : level.hazards) { for (LevelDefinition.Hazard item : level.hazards) {
hazardRuntimes.add(new HazardRuntime(item)); hazardRuntimes.add(new HazardRuntime(item));
} }
@@ -516,6 +572,7 @@ public final class GameView extends View implements Runnable {
int substeps = 2; int substeps = 2;
float step = dt / substeps; float step = dt / substeps;
for (int i = 0; i < substeps; i++) { for (int i = 0; i < substeps; i++) {
updatePhysicalObjects(step);
updateCorpses(step); updateCorpses(step);
if (player.alive) updatePlayer(step); if (player.alive) updatePlayer(step);
} }
@@ -633,10 +690,12 @@ public final class GameView extends View implements Runnable {
private boolean switchContact(LevelDefinition.Switch item) { private boolean switchContact(LevelDefinition.Switch item) {
int mass = 0; int mass = 0;
if (player.alive && intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, item.box)) mass++; mass += PressureMassResolver.contribution(item.box, player);
for (Body corpse : corpses) { for (Body corpse : corpses) {
if (!corpse.activatesPressure()) continue; mass += PressureMassResolver.contribution(item.box, corpse);
if (intersects(corpse.x, corpse.y, corpse.width, corpse.height, item.box)) mass++; }
for (PhysicalObjectState object : physicalObjects) {
mass += PressureMassResolver.contribution(item.box, object);
} }
return mass >= item.requiredMass; return mass >= item.requiredMass;
} }
@@ -675,6 +734,13 @@ public final class GameView extends View implements Runnable {
corpse.y += support.y - support.previousY; corpse.y += support.y - support.previousY;
} }
} }
for (PhysicalObjectState object : physicalObjects) {
if (object.supportIndex >= 0 && object.supportIndex < platformStates.size()) {
PlatformState support = platformStates.get(object.supportIndex);
object.x += support.x - support.previousX;
object.y += support.y - support.previousY;
}
}
} }
private void updatePlayer(float dt) { private void updatePlayer(float dt) {
@@ -712,6 +778,19 @@ public final class GameView extends View implements Runnable {
if (amount == 0f) return; if (amount == 0f) return;
player.x += amount; player.x += amount;
RectF current = playerRect(); RectF current = playerRect();
for (PhysicalObjectState object : physicalObjects) {
RectF solid = physicalObjectRect(object);
if (!RectF.intersects(current, solid)) continue;
boolean moved = movePhysicalObjectHorizontal(object, amount);
solid = physicalObjectRect(object);
current = playerRect();
if (!moved || RectF.intersects(current, solid)) {
if (amount > 0f) player.x = solid.left - PLAYER_WIDTH;
else player.x = solid.right;
player.vx = 0f;
current = playerRect();
}
}
for (RectF solid : solidRects(true)) { for (RectF solid : solidRects(true)) {
if (!RectF.intersects(current, solid)) continue; if (!RectF.intersects(current, solid)) continue;
if (amount > 0f) player.x = solid.left - PLAYER_WIDTH; if (amount > 0f) player.x = solid.left - PLAYER_WIDTH;
@@ -765,6 +844,107 @@ public final class GameView extends View implements Runnable {
} }
current = playerRect(); current = playerRect();
} }
for (PhysicalObjectState object : physicalObjects) {
RectF solid = physicalObjectRect(object);
if (!RectF.intersects(current, solid)) continue;
if (amount >= 0f) {
player.y = solid.top - PLAYER_HEIGHT;
player.vy = 0f;
player.onGround = true;
} else {
player.y = solid.bottom;
player.vy = 0f;
}
current = playerRect();
}
}
private void updatePhysicalObjects(float dt) {
for (PhysicalObjectState object : physicalObjects) {
object.vy += GRAVITY * dt;
object.grounded = false;
if (object.vx != 0f) movePhysicalObjectHorizontal(object, object.vx * dt);
object.y += object.vy * dt;
resolvePhysicalObjectVertical(object);
if (object.grounded) object.vx *= (float) Math.pow(0.04, dt);
if (Math.abs(object.vx) < 3f) object.vx = 0f;
}
}
private boolean movePhysicalObjectHorizontal(PhysicalObjectState object, float amount) {
if (amount == 0f) return true;
float before = object.x;
object.x += amount;
RectF current = physicalObjectRect(object);
for (PlatformState platform : platformStates) {
RectF solid = platform.rect();
if (!RectF.intersects(current, solid)) continue;
object.x = amount > 0f ? solid.left - object.width : solid.right;
current = physicalObjectRect(object);
}
for (LevelDefinition.Door door : level.doors) {
if (doorOpen(door)) continue;
RectF solid = rect(door.box);
if (!RectF.intersects(current, solid)) continue;
object.x = amount > 0f ? solid.left - object.width : solid.right;
current = physicalObjectRect(object);
}
for (Body corpse : corpses) {
RectF solid = corpseRect(corpse);
if (!RectF.intersects(current, solid)) continue;
object.x = amount > 0f ? solid.left - object.width : solid.right;
current = physicalObjectRect(object);
}
for (PhysicalObjectState other : physicalObjects) {
if (other == object) continue;
RectF solid = physicalObjectRect(other);
if (!RectF.intersects(current, solid)) continue;
object.x = amount > 0f ? solid.left - object.width : solid.right;
current = physicalObjectRect(object);
}
object.x = GameMath.clamp(object.x, 0f, level.worldWidth - object.width);
if (Math.abs(object.x - before) + 0.01f < Math.abs(amount)) object.vx = 0f;
return Math.abs(object.x - before) + 0.01f >= Math.abs(amount);
}
private void resolvePhysicalObjectVertical(PhysicalObjectState object) {
object.supportIndex = -1;
RectF current = physicalObjectRect(object);
for (int i = 0; i < platformStates.size(); i++) {
RectF solid = platformStates.get(i).rect();
if (!RectF.intersects(current, solid)) continue;
if (object.vy >= 0f) {
object.y = solid.top - object.height;
object.vy = 0f;
object.grounded = true;
object.supportIndex = i;
} else {
object.y = solid.bottom;
object.vy = 0f;
}
current = physicalObjectRect(object);
}
for (LevelDefinition.Door door : level.doors) {
if (doorOpen(door)) continue;
RectF solid = rect(door.box);
if (!RectF.intersects(current, solid)) continue;
if (object.vy >= 0f) {
object.y = solid.top - object.height;
object.grounded = true;
} else object.y = solid.bottom;
object.vy = 0f;
current = physicalObjectRect(object);
}
for (Body corpse : corpses) {
RectF solid = corpseRect(corpse);
if (!RectF.intersects(current, solid)) continue;
if (object.vy >= 0f) {
object.y = solid.top - object.height;
object.grounded = true;
} else object.y = solid.bottom;
object.vy = 0f;
current = physicalObjectRect(object);
}
} }
private void updateCorpses(float dt) { private void updateCorpses(float dt) {
@@ -848,6 +1028,9 @@ public final class GameView extends View implements Runnable {
for (PlatformState platform : platformStates) result.add(platform.rect()); for (PlatformState platform : platformStates) result.add(platform.rect());
for (LevelDefinition.Door door : level.doors) if (!doorOpen(door)) result.add(rect(door.box)); for (LevelDefinition.Door door : level.doors) if (!doorOpen(door)) result.add(rect(door.box));
if (includeCorpses) for (Body corpse : corpses) result.add(corpseRect(corpse)); if (includeCorpses) for (Body corpse : corpses) result.add(corpseRect(corpse));
for (PhysicalObjectState object : physicalObjects) {
result.add(physicalObjectRect(object));
}
return result; return result;
} }
@@ -978,6 +1161,11 @@ public final class GameView extends View implements Runnable {
corpse.x + corpse.width, corpse.y + corpse.height); corpse.x + corpse.width, corpse.y + corpse.height);
} }
private static RectF physicalObjectRect(PhysicalObjectState object) {
return new RectF(object.x, object.y,
object.x + object.width, object.y + object.height);
}
private static RectF rect(LevelDefinition.Box box) { private static RectF rect(LevelDefinition.Box box) {
return new RectF(box.x, box.y, box.right(), box.bottom()); return new RectF(box.x, box.y, box.right(), box.bottom());
} }
@@ -1016,13 +1204,14 @@ public final class GameView extends View implements Runnable {
private void recordPerformanceComplexity() { private void recordPerformanceComplexity() {
if (level == null) return; if (level == null) return;
int bodies = corpses.size(); int bodies = corpses.size();
int solids = platformStates.size() + level.doors.size() + bodies; int objects = physicalObjects.size();
int actors = 1 + bodies; int solids = platformStates.size() + level.doors.size() + bodies + objects;
int actors = 1 + bodies + objects;
int collisionChecks = solids * actors * 2 int collisionChecks = solids * actors * 2
+ level.hazards.size() + level.switches.size() * actors; + level.hazards.size() + level.switches.size() * actors;
int drawPrimitives = 180 + platformStates.size() * 8 int drawPrimitives = 180 + platformStates.size() * 8
+ level.switches.size() * 9 + level.hazards.size() * 20 + level.switches.size() * 9 + level.hazards.size() * 20
+ level.doors.size() * 12 + bodies * 28; + level.doors.size() * 12 + bodies * 28 + objects * 8;
Runtime runtime = Runtime.getRuntime(); Runtime runtime = Runtime.getRuntime();
long usedMemory = runtime.totalMemory() - runtime.freeMemory(); long usedMemory = runtime.totalMemory() - runtime.freeMemory();
performanceMonitor.recordComplexity(bodies, collisionChecks, performanceMonitor.recordComplexity(bodies, collisionChecks,
@@ -1039,6 +1228,7 @@ public final class GameView extends View implements Runnable {
drawHazards(canvas); drawHazards(canvas);
drawDoors(canvas); drawDoors(canvas);
drawGoal(canvas); drawGoal(canvas);
for (PhysicalObjectState object : physicalObjects) drawPhysicalObject(canvas, object);
for (Body corpse : corpses) drawCorpse(canvas, corpse); for (Body corpse : corpses) drawCorpse(canvas, corpse);
VolunteerRig.Skin skin = VolunteerRig.Skin.forLevel(level.number); VolunteerRig.Skin skin = VolunteerRig.Skin.forLevel(level.number);
if (player.alive) { if (player.alive) {
@@ -1398,6 +1588,50 @@ public final class GameView extends View implements Runnable {
} }
} }
private void drawPhysicalObject(Canvas canvas, PhysicalObjectState object) {
RectF box = physicalObjectRect(object);
boolean contrast = accessibilitySettings.highContrastEnabled();
switch (object.type) {
case BOX:
paint.setColor(contrast ? Color.WHITE : Color.rgb(151, 103, 55));
canvas.drawRoundRect(box, 8f, 8f, paint);
stroke.setColor(contrast ? Color.BLACK : Color.rgb(225, 176, 91));
stroke.setStrokeWidth(5f);
canvas.drawLine(box.left + 8f, box.top + 8f,
box.right - 8f, box.bottom - 8f, stroke);
canvas.drawLine(box.right - 8f, box.top + 8f,
box.left + 8f, box.bottom - 8f, stroke);
break;
case ROCK:
paint.setColor(contrast ? Color.WHITE : Color.rgb(109, 112, 123));
canvas.drawOval(box, paint);
break;
case BALL:
paint.setColor(contrast ? Color.WHITE : Color.rgb(202, 126, 56));
canvas.drawCircle(box.centerX(), box.centerY(),
Math.min(box.width(), box.height()) * 0.5f, paint);
break;
case ICE_BLOCK:
paint.setColor(contrast ? Color.WHITE : Color.rgb(106, 209, 235));
canvas.drawRoundRect(box, 12f, 12f, paint);
stroke.setColor(contrast ? Color.BLACK : Color.rgb(221, 252, 255));
stroke.setStrokeWidth(5f);
canvas.drawLine(box.left + 10f, box.bottom - 12f,
box.right - 12f, box.top + 10f, stroke);
break;
case COUNTERWEIGHT:
paint.setColor(contrast ? Color.WHITE : Color.rgb(89, 79, 66));
canvas.drawRoundRect(box, 14f, 14f, paint);
stroke.setColor(contrast ? Color.BLACK : Color.rgb(237, 190, 77));
stroke.setStrokeWidth(7f);
canvas.drawLine(box.centerX(), box.top + 10f,
box.centerX(), box.bottom - 10f, stroke);
break;
}
drawWorldLabel(canvas, object.type.name() + " W" + object.mass,
box.centerX(), box.top - 16f);
}
private void drawHud(Canvas canvas) { private void drawHud(Canvas canvas) {
ensureHudTextCache(); ensureHudTextCache();
paint.setColor(Color.argb(178, 15, 20, 34)); paint.setColor(Color.argb(178, 15, 20, 34));
@@ -2575,6 +2809,12 @@ public final class GameView extends View implements Runnable {
corpse.supportIndex, corpse.supportIndex,
corpse.deathType())); corpse.deathType()));
} }
for (PhysicalObjectState object : physicalObjects) {
builder.addObject(new GameSessionSnapshot.ObjectData(
object.type, object.x, object.y, object.width, object.height,
object.vx, object.vy, object.mass, object.grounded,
object.supportIndex));
}
ArrayList<Float> motionTimes = new ArrayList<>(platformStates.size()); ArrayList<Float> motionTimes = new ArrayList<>(platformStates.size());
for (PlatformState state : platformStates) motionTimes.add(state.motionTime); for (PlatformState state : platformStates) motionTimes.add(state.motionTime);
builder.platformMotionTimes(motionTimes); builder.platformMotionTimes(motionTimes);
@@ -2597,6 +2837,8 @@ public final class GameView extends View implements Runnable {
LevelDefinition definition = LevelCatalog.get(index); LevelDefinition definition = LevelCatalog.get(index);
if (snapshot.platformMotionTimes().size() != definition.platforms.size() if (snapshot.platformMotionTimes().size() != definition.platforms.size()
|| snapshot.bodies().size() > MAX_CORPSES) return false; || snapshot.bodies().size() > MAX_CORPSES) return false;
if (!snapshot.objects().isEmpty()
&& snapshot.objects().size() != definition.objects.size()) return false;
if (!validSupport(snapshot.player().supportIndex(), definition.platforms.size())) { if (!validSupport(snapshot.player().supportIndex(), definition.platforms.size())) {
return false; return false;
} }
@@ -2608,6 +2850,13 @@ public final class GameView extends View implements Runnable {
return false; return false;
} }
} }
for (int i = 0; i < snapshot.objects().size(); i++) {
GameSessionSnapshot.ObjectData object = snapshot.objects().get(i);
LevelDefinition.PhysicalObject authored = definition.objects.get(i);
if (!validSupport(object.supportIndex(), definition.platforms.size())
|| object.type() != authored.type || object.mass() != authored.mass
|| object.width() <= 0f || object.height() <= 0f) return false;
}
startLevel(index); startLevel(index);
levelElapsed = snapshot.elapsedSeconds(); levelElapsed = snapshot.elapsedSeconds();
@@ -2664,6 +2913,12 @@ public final class GameView extends View implements Runnable {
corpseManager.transform(corpse, CorpseManager.Transformation.BURNED); corpseManager.transform(corpse, CorpseManager.Transformation.BURNED);
} }
} }
if (!snapshot.objects().isEmpty()) {
physicalObjects.clear();
for (GameSessionSnapshot.ObjectData savedObject : snapshot.objects()) {
physicalObjects.add(new PhysicalObjectState(savedObject));
}
}
updateSignals(0f, false); updateSignals(0f, false);
leftPressed = false; leftPressed = false;
@@ -2721,6 +2976,8 @@ public final class GameView extends View implements Runnable {
firstHazardY = firstHazardBox.y; firstHazardY = firstHazardBox.y;
} }
Body firstCorpse = corpses.isEmpty() ? null : corpses.get(0); Body firstCorpse = corpses.isEmpty() ? null : corpses.get(0);
PhysicalObjectState firstObject = physicalObjects.isEmpty()
? null : physicalObjects.get(0);
return new AutomationSnapshot( return new AutomationSnapshot(
screen == Screen.COMPLETE, screen == Screen.COMPLETE,
player.alive, player.alive,
@@ -2750,7 +3007,12 @@ public final class GameView extends View implements Runnable {
firstCorpse != null && firstCorpse.crushed(), firstCorpse != null && firstCorpse.crushed(),
firstCorpse != null && firstCorpse.activatesPressure(), firstCorpse != null && firstCorpse.activatesPressure(),
firstCorpse == null ? Float.NaN : firstCorpse.width(), firstCorpse == null ? Float.NaN : firstCorpse.width(),
firstCorpse == null ? Float.NaN : firstCorpse.height() firstCorpse == null ? Float.NaN : firstCorpse.height(),
physicalObjects.size(),
firstObject == null ? Float.NaN : firstObject.x,
firstObject == null ? Float.NaN : firstObject.y,
firstObject == null ? 0 : firstObject.mass,
firstObject == null ? "" : firstObject.type.name()
); );
} }
@@ -0,0 +1,11 @@
package com.projectsacrifice.game;
/** Shared pressure-plate contract for living, retained, and authored objects. */
public interface IWeightedObject {
float weightedX();
float weightedY();
float weightedWidth();
float weightedHeight();
int pressureMass();
boolean activatesPressure();
}
@@ -0,0 +1,61 @@
package com.projectsacrifice.game;
/** Mutable deterministic runtime for one authored weighted object. */
public final class PhysicalObjectState implements IWeightedObject {
final LevelDefinition.PhysicalObjectType type;
final int mass;
float x, y, width, height, vx, vy;
boolean grounded;
int supportIndex = -1;
public PhysicalObjectState(LevelDefinition.PhysicalObject definition) {
if (definition == null) throw new IllegalArgumentException("definition is required");
type = definition.type;
mass = definition.mass;
x = definition.box.x;
y = definition.box.y;
width = definition.box.width;
height = definition.box.height;
}
PhysicalObjectState(GameSessionSnapshot.ObjectData saved) {
type = saved.type();
mass = saved.mass();
x = saved.x();
y = saved.y();
width = saved.width();
height = saved.height();
vx = saved.velocityX();
vy = saved.velocityY();
grounded = saved.grounded();
supportIndex = saved.supportIndex();
}
public PhysicalObjectState position(float valueX, float valueY) {
x = valueX;
y = valueY;
return this;
}
public PhysicalObjectState velocity(float valueX, float valueY) {
vx = valueX;
vy = valueY;
return this;
}
public PhysicalObjectState grounded(boolean value) { grounded = value; return this; }
public PhysicalObjectState supportIndex(int value) { supportIndex = value; return this; }
public LevelDefinition.PhysicalObjectType type() { return type; }
public float velocityX() { return vx; }
public float velocityY() { return vy; }
public boolean grounded() { return grounded; }
public int supportIndex() { return supportIndex; }
@Override public float weightedX() { return x; }
@Override public float weightedY() { return y; }
@Override public float weightedWidth() { return width; }
@Override public float weightedHeight() { return height; }
@Override public int pressureMass() { return mass; }
@Override public boolean activatesPressure() { return true; }
}
@@ -0,0 +1,31 @@
package com.projectsacrifice.game;
/** Pure mass aggregation; it deliberately has no knowledge of object kinds. */
public final class PressureMassResolver {
private PressureMassResolver() {}
public static boolean active(LevelDefinition.Box plate, int requiredMass,
Iterable<? extends IWeightedObject> objects) {
int mass = 0;
if (plate == null || objects == null || requiredMass <= 0) return false;
for (IWeightedObject object : objects) {
if (object == null || !object.activatesPressure()) continue;
if (!intersects(object, plate)) continue;
mass += Math.max(0, object.pressureMass());
if (mass >= requiredMass) return true;
}
return false;
}
public static int contribution(LevelDefinition.Box plate, IWeightedObject object) {
if (plate == null || object == null || !object.activatesPressure()) return 0;
return intersects(object, plate) ? Math.max(0, object.pressureMass()) : 0;
}
private static boolean intersects(IWeightedObject object, LevelDefinition.Box box) {
return object.weightedX() < box.right()
&& object.weightedX() + object.weightedWidth() > box.x
&& object.weightedY() < box.bottom()
&& object.weightedY() + object.weightedHeight() > box.y;
}
}
@@ -12,9 +12,10 @@ import java.util.Map;
public final class VersionedSessionCodec { public final class VersionedSessionCodec {
private static final int MAGIC = 0x47534e50; // GSNP private static final int MAGIC = 0x47534e50; // GSNP
private static final int MAX_BODIES = 16; private static final int MAX_BODIES = 16;
private static final int MAX_OBJECTS = 64;
private static final int MAX_PLATFORMS = 64; private static final int MAX_PLATFORMS = 64;
private static final int MAX_MAP_ENTRIES = 128; private static final int MAX_MAP_ENTRIES = 128;
public static final int CURRENT_VERSION = 3; public static final int CURRENT_VERSION = 4;
public enum Status { RESTORED, MIGRATED, UNSUPPORTED, CORRUPT } public enum Status { RESTORED, MIGRATED, UNSUPPORTED, CORRUPT }
@@ -45,6 +46,10 @@ public final class VersionedSessionCodec {
return encodeWithVersion(snapshot, 2); return encodeWithVersion(snapshot, 2);
} }
static byte[] encodeLegacyV3(GameSessionSnapshot snapshot) {
return encodeWithVersion(snapshot, 3);
}
static byte[] encodeVersionForTest(int version) { static byte[] encodeVersionForTest(int version) {
try { try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream(); ByteArrayOutputStream bytes = new ByteArrayOutputStream();
@@ -80,6 +85,10 @@ public final class VersionedSessionCodec {
int bodyCount = checkedCount(input.readInt(), MAX_BODIES); int bodyCount = checkedCount(input.readInt(), MAX_BODIES);
for (int i = 0; i < bodyCount; i++) builder.addBody(readBody(input, version)); for (int i = 0; i < bodyCount; i++) builder.addBody(readBody(input, version));
if (version >= 4) {
int objectCount = checkedCount(input.readInt(), MAX_OBJECTS);
for (int i = 0; i < objectCount; i++) builder.addObject(readObject(input));
}
int platformCount = checkedCount(input.readInt(), MAX_PLATFORMS); int platformCount = checkedCount(input.readInt(), MAX_PLATFORMS);
ArrayList<Float> motionTimes = new ArrayList<>(platformCount); ArrayList<Float> motionTimes = new ArrayList<>(platformCount);
@@ -115,6 +124,12 @@ public final class VersionedSessionCodec {
for (GameSessionSnapshot.BodyData body : snapshot.bodies()) { for (GameSessionSnapshot.BodyData body : snapshot.bodies()) {
writeBody(output, body, version); writeBody(output, body, version);
} }
if (version >= 4) {
output.writeInt(snapshot.objects().size());
for (GameSessionSnapshot.ObjectData object : snapshot.objects()) {
writeObject(output, object);
}
}
output.writeInt(snapshot.platformMotionTimes().size()); output.writeInt(snapshot.platformMotionTimes().size());
for (Float time : snapshot.platformMotionTimes()) output.writeFloat(time); for (Float time : snapshot.platformMotionTimes()) output.writeFloat(time);
writeBooleanMap(output, snapshot.toggleSignals()); writeBooleanMap(output, snapshot.toggleSignals());
@@ -194,6 +209,39 @@ public final class VersionedSessionCodec {
crushed, burned, support, deathType); crushed, burned, support, deathType);
} }
private static void writeObject(DataOutputStream output,
GameSessionSnapshot.ObjectData object) throws IOException {
output.writeUTF(object.type().name());
output.writeFloat(object.x());
output.writeFloat(object.y());
output.writeFloat(object.width());
output.writeFloat(object.height());
output.writeFloat(object.velocityX());
output.writeFloat(object.velocityY());
output.writeInt(object.mass());
output.writeBoolean(object.grounded());
output.writeInt(object.supportIndex());
}
private static GameSessionSnapshot.ObjectData readObject(DataInputStream input)
throws IOException {
String typeName = input.readUTF();
LevelDefinition.PhysicalObjectType type =
LevelDefinition.PhysicalObjectType.valueOf(typeName);
float x = finite(input.readFloat());
float y = finite(input.readFloat());
float width = finite(input.readFloat());
float height = finite(input.readFloat());
float velocityX = finite(input.readFloat());
float velocityY = finite(input.readFloat());
int mass = input.readInt();
if (mass <= 0) throw new IllegalArgumentException("Object mass must be positive");
boolean grounded = input.readBoolean();
int supportIndex = input.readInt();
return new GameSessionSnapshot.ObjectData(type, x, y, width, height,
velocityX, velocityY, mass, grounded, supportIndex);
}
private static void writeBooleanMap(DataOutputStream output, Map<String, Boolean> values) private static void writeBooleanMap(DataOutputStream output, Map<String, Boolean> values)
throws IOException { throws IOException {
output.writeInt(values.size()); output.writeInt(values.size());
@@ -80,6 +80,9 @@ public final class CorpseManagerTest {
assertFalse(body.crushed()); assertFalse(body.crushed());
assertFalse(body.activatesPressure()); assertFalse(body.activatesPressure());
assertTrue(manager.bodies().contains(body)); assertTrue(manager.bodies().contains(body));
assertTrue(body instanceof IWeightedObject);
assertEquals(1, body.pressureMass());
assertEquals(body.x(), body.weightedX(), 0f);
} }
@Test @Test
@@ -6,6 +6,7 @@ import static org.junit.Assert.assertTrue;
import org.junit.Test; import org.junit.Test;
import java.util.Arrays;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@@ -14,7 +15,7 @@ public final class LevelCatalogTest {
@Test @Test
public void productionCatalogContainsHazardsAndTwoCorpseStateTeachingLevels() { public void productionCatalogContainsHazardsAndTwoCorpseStateTeachingLevels() {
List<LevelDefinition> levels = LevelCatalog.all(); List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(16, levels.size()); assertEquals(17, levels.size());
Set<String> titles = new HashSet<>(); Set<String> titles = new HashSet<>();
for (int i = 0; i < levels.size(); i++) { for (int i = 0; i < levels.size(); i++) {
LevelDefinition level = levels.get(i); LevelDefinition level = levels.get(i);
@@ -29,6 +30,16 @@ public final class LevelCatalogTest {
assertTrue(levels.get(14).containsMechanic(LevelDefinition.HazardType.CRUSHER)); assertTrue(levels.get(14).containsMechanic(LevelDefinition.HazardType.CRUSHER));
assertTrue(levels.get(15).containsMechanic(LevelDefinition.HazardType.FIRE)); assertTrue(levels.get(15).containsMechanic(LevelDefinition.HazardType.FIRE));
assertFalse(levels.get(15).platforms.get(1).controlId.isEmpty()); assertFalse(levels.get(15).platforms.get(1).controlId.isEmpty());
LevelDefinition weighted = levels.get(16);
assertEquals(5, weighted.objects.size());
Set<LevelDefinition.PhysicalObjectType> objectTypes = new HashSet<>();
for (LevelDefinition.PhysicalObject object : weighted.objects) {
objectTypes.add(object.type);
}
assertEquals(new HashSet<>(Arrays.asList(LevelDefinition.PhysicalObjectType.values())),
objectTypes);
assertEquals(9, weighted.switches.get(0).requiredMass);
assertFalse(weighted.doors.isEmpty());
} }
@Test @Test
@@ -214,6 +214,43 @@ public final class LevelJsonLoaderTest {
assertEquals("explosion-blast", explosion.audioCue); assertEquals("explosion-blast", explosion.audioCue);
} }
@Test
public void allApprovedWeightedObjectTypesLoadThroughOneMassContract() {
String objects = "\"objects\":["
+ "{\"type\":\"BOX\",\"box\":{\"x\":300,\"y\":800,\"width\":80,\"height\":80},\"mass\":1},"
+ "{\"type\":\"ROCK\",\"box\":{\"x\":400,\"y\":820,\"width\":60,\"height\":60},\"mass\":2},"
+ "{\"type\":\"BALL\",\"box\":{\"x\":500,\"y\":820,\"width\":60,\"height\":60},\"mass\":1},"
+ "{\"type\":\"ICE_BLOCK\",\"box\":{\"x\":600,\"y\":800,\"width\":80,\"height\":80},\"mass\":2},"
+ "{\"type\":\"COUNTERWEIGHT\",\"box\":{\"x\":700,\"y\":780,\"width\":80,\"height\":100},\"mass\":3}],";
String json = validLevelJson(1).replace("\"platforms\":", objects + "\"platforms\":");
LevelDefinition level = LevelJsonLoader.load(new StringReader(json), "weighted.json");
assertEquals(5, level.objects.size());
assertEquals(LevelDefinition.PhysicalObjectType.BOX, level.objects.get(0).type);
assertEquals(LevelDefinition.PhysicalObjectType.ROCK, level.objects.get(1).type);
assertEquals(LevelDefinition.PhysicalObjectType.BALL, level.objects.get(2).type);
assertEquals(LevelDefinition.PhysicalObjectType.ICE_BLOCK, level.objects.get(3).type);
assertEquals(LevelDefinition.PhysicalObjectType.COUNTERWEIGHT, level.objects.get(4).type);
assertEquals(3, level.objects.get(4).mass);
}
@Test
public void weightedObjectRejectsNonPositiveMassWithItsJsonPath() {
String object = "\"objects\":[{\"type\":\"BOX\","
+ "\"box\":{\"x\":300,\"y\":800,\"width\":80,\"height\":80},\"mass\":0}],";
String json = validLevelJson(1).replace("\"platforms\":", object + "\"platforms\":");
try {
LevelJsonLoader.load(new StringReader(json), "massless.json");
fail("A pressure object with no mass must fail closed");
} catch (IllegalArgumentException error) {
assertTrue(error.getMessage(), error.getMessage().contains(
"massless.json.objects[0].mass"));
assertTrue(error.getMessage(), error.getMessage().contains("must be positive"));
}
}
static String validLevelJson(int formatVersion) { static String validLevelJson(int formatVersion) {
return "{" return "{"
+ "\"formatVersion\":" + formatVersion + "," + "\"formatVersion\":" + formatVersion + ","
@@ -8,8 +8,8 @@ import org.junit.Test;
public final class LevelPageTest { public final class LevelPageTest {
@Test @Test
public void sixteenLevelsUseTwoBoundedTenCardPages() { public void seventeenLevelsUseTwoBoundedTenCardPages() {
LevelPage page = new LevelPage(16, 10); LevelPage page = new LevelPage(17, 10);
assertEquals(2, page.pageCount()); assertEquals(2, page.pageCount());
assertEquals(0, page.firstIndex()); assertEquals(0, page.firstIndex());
@@ -19,7 +19,7 @@ public final class LevelPageTest {
page.next(); page.next();
assertEquals(10, page.firstIndex()); assertEquals(10, page.firstIndex());
assertEquals(16, page.endExclusive()); assertEquals(17, page.endExclusive());
assertTrue(page.hasPrevious()); assertTrue(page.hasPrevious());
assertFalse(page.hasNext()); assertFalse(page.hasNext());
@@ -29,5 +29,7 @@ public final class LevelPageTest {
assertEquals(0, page.pageIndex()); assertEquals(0, page.pageIndex());
page.selectIndex(15); page.selectIndex(15);
assertEquals(1, page.pageIndex()); assertEquals(1, page.pageIndex());
page.selectIndex(16);
assertEquals(1, page.pageIndex());
} }
} }
@@ -0,0 +1,30 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public final class PhysicalObjectStateTest {
@Test
public void authoredObjectPublishesStableMassGeometryAndRuntimeState() {
LevelDefinition.PhysicalObject definition = new LevelDefinition.PhysicalObject(
LevelDefinition.PhysicalObjectType.COUNTERWEIGHT,
new LevelDefinition.Box(420f, 760f, 80f, 120f), 3);
PhysicalObjectState state = new PhysicalObjectState(definition);
state.velocity(120f, -30f).grounded(true).supportIndex(2);
assertEquals(LevelDefinition.PhysicalObjectType.COUNTERWEIGHT, state.type());
assertEquals(420f, state.weightedX(), 0f);
assertEquals(760f, state.weightedY(), 0f);
assertEquals(80f, state.weightedWidth(), 0f);
assertEquals(120f, state.weightedHeight(), 0f);
assertEquals(3, state.pressureMass());
assertTrue(state.activatesPressure());
assertEquals(120f, state.velocityX(), 0f);
assertEquals(-30f, state.velocityY(), 0f);
assertTrue(state.grounded());
assertEquals(2, state.supportIndex());
}
}
@@ -29,6 +29,10 @@ public final class VersionedSessionCodecTest {
assertTrue(restored.bodies().get(1).conductive()); assertTrue(restored.bodies().get(1).conductive());
assertTrue(restored.bodies().get(0).crushed()); assertTrue(restored.bodies().get(0).crushed());
assertTrue(restored.bodies().get(1).burned()); assertTrue(restored.bodies().get(1).burned());
assertEquals(2, restored.objects().size());
assertEquals(LevelDefinition.PhysicalObjectType.BOX,
restored.objects().get(0).type());
assertEquals(3, restored.objects().get(1).mass());
assertEquals(Arrays.asList(1.25f, 3.5f), restored.platformMotionTimes()); assertEquals(Arrays.asList(1.25f, 3.5f), restored.platformMotionTimes());
assertTrue(restored.toggleSignals().get("gate")); assertTrue(restored.toggleSignals().get("gate"));
assertEquals(2.25f, restored.timedSignals().get("clock"), 0f); assertEquals(2.25f, restored.timedSignals().get("clock"), 0f);
@@ -58,6 +62,18 @@ public final class VersionedSessionCodecTest {
assertTrue(decoded.snapshot().previousSwitchContacts().get("gate@420.0")); assertTrue(decoded.snapshot().previousSwitchContacts().get("gate@420.0"));
assertFalse(decoded.snapshot().bodies().get(0).crushed()); assertFalse(decoded.snapshot().bodies().get(0).crushed());
assertFalse(decoded.snapshot().bodies().get(1).burned()); assertFalse(decoded.snapshot().bodies().get(1).burned());
assertTrue(decoded.snapshot().objects().isEmpty());
}
@Test
public void legacyV3MigratesWithSafeEmptyPhysicalObjectRuntime() {
VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode(
VersionedSessionCodec.encodeLegacyV3(sampleSnapshot()));
assertEquals(VersionedSessionCodec.Status.MIGRATED, decoded.status());
assertTrue(decoded.snapshot().bodies().get(0).crushed());
assertTrue(decoded.snapshot().bodies().get(1).burned());
assertTrue(decoded.snapshot().objects().isEmpty());
} }
@Test @Test
@@ -105,6 +121,12 @@ public final class VersionedSessionCodecTest {
720f, 826f, 72f, 54f, 0f, 0f, 720f, 826f, 72f, 54f, 0f, 0f,
false, true, true, true, false, true, false, true, true, true, false, true,
-1, "FIRE")) -1, "FIRE"))
.addObject(new GameSessionSnapshot.ObjectData(
LevelDefinition.PhysicalObjectType.BOX,
300f, 800f, 80f, 80f, 20f, 0f, 1, true, -1))
.addObject(new GameSessionSnapshot.ObjectData(
LevelDefinition.PhysicalObjectType.COUNTERWEIGHT,
700f, 780f, 80f, 100f, 0f, 0f, 3, true, 1))
.platformMotionTimes(Arrays.asList(1.25f, 3.5f)) .platformMotionTimes(Arrays.asList(1.25f, 3.5f))
.toggleSignal("gate", true) .toggleSignal("gate", true)
.timedSignal("clock", 2.25f) .timedSignal("clock", 2.25f)
@@ -0,0 +1,45 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import java.util.Arrays;
import org.junit.Test;
public final class WeightedPressureResolverTest {
@Test
public void plateUsesOneMassContractForPlayerCorpseAndPhysicalObject() {
LevelDefinition.Box plate = new LevelDefinition.Box(100f, 200f, 180f, 20f);
IWeightedObject player = item(110f, 150f, 62f, 100f, 1, true);
IWeightedObject corpse = item(170f, 166f, 72f, 54f, 1, true);
IWeightedObject counterweight = item(230f, 140f, 70f, 80f, 3, true);
assertFalse(PressureMassResolver.active(
plate, 6, Arrays.asList(player, corpse, counterweight)));
assertTrue(PressureMassResolver.active(
plate, 5, Arrays.asList(player, corpse, counterweight)));
}
@Test
public void inactiveAndNonIntersectingObjectsNeverContributeMass() {
LevelDefinition.Box plate = new LevelDefinition.Box(100f, 200f, 180f, 20f);
IWeightedObject burnedCorpse = item(120f, 166f, 72f, 54f, 5, false);
IWeightedObject distantRock = item(500f, 160f, 60f, 60f, 5, true);
assertFalse(PressureMassResolver.active(
plate, 1, Arrays.asList(burnedCorpse, distantRock)));
}
private static IWeightedObject item(float x, float y, float width, float height,
int mass, boolean active) {
return new IWeightedObject() {
@Override public float weightedX() { return x; }
@Override public float weightedY() { return y; }
@Override public float weightedWidth() { return width; }
@Override public float weightedHeight() { return height; }
@Override public int pressureMass() { return mass; }
@Override public boolean activatesPressure() { return active; }
};
}
}
+11 -1
View File
@@ -160,6 +160,13 @@ baseline-задачи также имели явного владельца по
переход дальше; границы доступного human evidence зафиксированы в переход дальше; границы доступного human evidence зафиксированы в
`STAGE5_HUMAN_PLAYTEST_RESULT.md`. Stage 6 разблокирован. `STAGE5_HUMAN_PLAYTEST_RESULT.md`. Stage 6 разблокирован.
Текущий статус Stage 6: foundation, production hazards, burned/crushed corpse
states и weighted objects приняты. Каталог содержит 17 уровней; последний exact
candidate прошёл `108` app JVM, `2` content-tool JVM и нефильтрованную Android 16
матрицу `48/48` за `2,223.695 s`. Следующий dependency-order slice — challenges
и production progression, после него начинается массовое производство четырёх
миров.
### Этап 6. Production content — P1, несколько итераций ### Этап 6. Production content — P1, несколько итераций
Задачи: Задачи:
@@ -191,7 +198,10 @@ data-driven модель. Завершён первый mechanics slice: product
99 JVM-тестов и Android 16 matrix `43/43` (`2004.172 s`). Следующая зависимая 99 JVM-тестов и Android 16 matrix `43/43` (`2004.172 s`). Следующая зависимая
итерация завершена: `CRUSHED` и `BURNED`, save migration и обучающие уровни итерация завершена: `CRUSHED` и `BURNED`, save migration и обучающие уровни
15–16 приняты clean gate `102 JVM` и unfiltered Android matrix `46/46` 15–16 приняты clean gate `102 JVM` и unfiltered Android matrix `46/46`
(`1,842.248 s`). Доказательства зафиксированы в `STAGE6_CORPSE_STATES.md`. (`1,842.248 s`). Weighted-object slice также принят: общий `IWeightedObject`,
Box/Rock/Ball/IceBlock/Counterweight, save v4 и обучающий уровень 17 прошли clean
gate `108` app JVM + `2` content-tool JVM и unfiltered Android matrix `48/48`
(`2,223.695 s`). Evidence зафиксирован в `STAGE6_WEIGHTED_OBJECTS.md`.
### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней ### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней
+31 -2
View File
@@ -1,8 +1,8 @@
# Графы решений уровней 1–16 # Графы решений уровней 1–17
Документ фиксирует **авторский маршрут** каждого уровня текущего vertical slice. Он получен из `LevelCatalog`, моделей `LevelDefinition` и фактической логики `GameView`, затем проверен device-level ботами через production-команды движения и прыжка. `targetDeaths` ниже — целевое число жертв для результата уровня, но оно не доказывает минимальность решения. Документ фиксирует **авторский маршрут** каждого уровня текущего vertical slice. Он получен из `LevelCatalog`, моделей `LevelDefinition` и фактической логики `GameView`, затем проверен device-level ботами через production-команды движения и прыжка. `targetDeaths` ниже — целевое число жертв для результата уровня, но оно не доказывает минимальность решения.
На 19 августа 2026 года intended route всех уровней 1–16 автоматически выполнен по три раза подряд без retry masking. Device-тесты управляют production `GameView` через те же флаги, что и touch-контролы. Для уровня 8 отдельно проверено восстановление после истечения таймера на дальней стороне `PIT`. На 19 августа 2026 года intended route всех уровней 1–17 автоматически выполнен по три раза подряд без retry masking. Device-тесты управляют production `GameView` через те же флаги, что и touch-контролы. Для уровня 8 отдельно проверено восстановление после истечения таймера на дальней стороне `PIT`.
## Семантика, общая для графов ## Семантика, общая для графов
@@ -405,6 +405,35 @@ Moving bridge над PIT управляется signal "burn-weight"
и нижней координатой моста. Save version 3 сохраняет флаг; Canvas добавляет и нижней координатой моста. Save version 3 сохраняет флаг; Canvas добавляет
текстовую метку `BURNED`, поэтому состояние не кодируется только цветом. текстовую метку `BURNED`, поэтому состояние не кодируется только цветом.
## Уровень 17 — `The Whole Weight`
**Статус:** доказан production-input ботом, 3/3 последовательных прохождения.
**Ожидаемые и доказанные смерти:** `0`; дверь обязана удерживаться только
физическими объектами после отпускания player input.
```text
Door требует signal "weight-gate" с requiredMass=9
→ на PRESSURE уже стоят Box(1), Rock(2), Ball(1) и IceBlock(2): сумма 6
→ Counterweight(3) находится слева от плиты, поэтому Door закрыта
→ игрок подходит и толкает Counterweight вправо
→ при x≥920 Counterweight пересекает plate x=1000..1650
→ общий IWeightedObject resolver получает массу 6+3=9 и открывает Door
→ игрок отпускает input; объект продолжает удерживать сигнал без player mass
→ игрок двумя отдельными прыжками пересекает Counterweight и ряд объектов
→ проходит открытую Door и достигает goal
→ COMPLETE без смерти и без создания тел
```
**Контракт:** `Player`, `CorpseManager.Body` и `PhysicalObjectState` реализуют
один `IWeightedObject`; plate агрегирует только геометрически пересекающуюся
активную массу, не проверяя тип. Все пять authored типов имеют type/box/mass в
JSON, детерминированный runtime, non-color type+weight label, SVG/TSV preview и
save-v4 состояние. V1–v3 active sessions мигрируют с пустым runtime-object list.
**Bot checkpoints:** каталог публикует пять objects и Counterweight mass 3;
до push `allDoorsOpen=false`; после push `firstPhysicalObjectX≥920` и Door open;
после release Door остаётся open; затем два отдельных jump edge и `completed=true`.
## Проверки, следующие из графов ## Проверки, следующие из графов
1. Сохранять полный integrated device-regression: intended route принятых уровней по три раза и отдельный recovery route уровня 8. 1. Сохранять полный integrated device-regression: intended route принятых уровней по три раза и отдельный recovery route уровня 8.
+12 -7
View File
@@ -2,7 +2,7 @@
## Назначение ## Назначение
Все десять уровней находятся в Android-independent модуле `level-format` и Все уровни каталога находятся в Android-independent модуле `level-format` и
загружаются из `level-format/src/main/resources/levels`. Runtime, unit-тесты и загружаются из `level-format/src/main/resources/levels`. Runtime, unit-тесты и
desktop-команды используют один и тот же JSON parser, catalog loader и validator. desktop-команды используют один и тот же JSON parser, catalog loader и validator.
Изменение геометрии или текста не требует изменения игрового Java-кода. Изменение геометрии или текста не требует изменения игрового Java-кода.
@@ -48,6 +48,9 @@ desktop-команды используют один и тот же JSON parser,
"spawn": {"x": 130, "y": 760}, "spawn": {"x": 130, "y": 760},
"goal": {"x": 1810, "y": 770, "width": 86, "height": 110}, "goal": {"x": 1810, "y": 770, "width": 86, "height": 110},
"targets": {"deaths": 1, "seconds": 50}, "targets": {"deaths": 1, "seconds": 50},
"objects": [
{"type": "COUNTERWEIGHT", "box": {"x": 300, "y": 780, "width": 80, "height": 100}, "mass": 3}
],
"platforms": [ "platforms": [
{"box": {"x": 0, "y": 880, "width": 2050, "height": 200}} {"box": {"x": 0, "y": 880, "width": 2050, "height": 200}}
], ],
@@ -68,7 +71,10 @@ desktop-команды используют один и тот же JSON parser,
Поддерживаемые значения: Поддерживаемые значения:
- hazards: `SPIKES`, `SAW`, `FREEZER`, `ELECTRIC`, `PIT`; - hazards: `SPIKES`, `SAW`, `FREEZER`, `ELECTRIC`, `PIT`, `CRUSHER`, `FIRE`,
`PROJECTILE`, `EXPLOSION`;
- physical objects: `BOX`, `ROCK`, `BALL`, `ICE_BLOCK`, `COUNTERWEIGHT`; каждый
задаёт `box` и положительный integer `mass`;
- switches: `PRESSURE`, `TOGGLE`, `TIMED`; - switches: `PRESSURE`, `TOGGLE`, `TIMED`;
- static platform: только `box`; - static platform: только `box`;
- moving platform: `box`, ненулевой `travelX` или `travelY`, положительный - moving platform: `box`, ненулевой `travelX` или `travelY`, положительный
@@ -82,7 +88,7 @@ desktop-команды используют один и тот же JSON parser,
- известные format/physics versions; - известные format/physics versions;
- конечные положительные размеры мира и объектов; - конечные положительные размеры мира и объектов;
- spawn, goal, платформы, hazards, switches, doors и конечную позицию moving - spawn, goal, physical objects, платформы, hazards, switches, doors и конечную позицию moving
platform внутри мира; platform внутри мира;
- наличие хотя бы одной платформы; - наличие хотя бы одной платформы;
- неотрицательный target deaths и положительный target seconds; - неотрицательный target deaths и положительный target seconds;
@@ -115,9 +121,9 @@ level-03.json.doors[0].requiredSignals[0] references missing signal 'missing'
Результат находится в `level-format/build/level-previews`: Результат находится в `level-format/build/level-previews`:
- `level-01.svg` … `level-10.svg` — world-space схема spawn/goal, платформ, - `level-01.svg` … последний уровень каталога — world-space схема spawn/goal,
hazards, switches и doors; objects, платформ, hazards, switches и doors;
- `catalog.tsv` — координаты, размеры, travel и signal/control references. - `catalog.tsv` — координаты, размеры, object mass, travel и signal/control references.
Вывод использует стабильный порядок и locale-independent числа. Два запуска на Вывод использует стабильный порядок и locale-independent числа. Два запуска на
неизменном каталоге должны давать byte-identical файлы. неизменном каталоге должны давать byte-identical файлы.
@@ -136,4 +142,3 @@ Wall-clock, device identifiers и прямые mutation-команды не за
`traceId` — SHA-256 точного UTF-8 JSON и всегда включён в failure diagnostic. `traceId` — SHA-256 точного UTF-8 JSON и всегда включён в failure diagnostic.
Stage 4 сохраняет воспроизводимый артефакт расследования, но намеренно не добавляет Stage 4 сохраняет воспроизводимый артефакт расследования, но намеренно не добавляет
runtime replay executor. runtime replay executor.
+54
View File
@@ -0,0 +1,54 @@
# Stage 6 weighted objects
Date: 19 August 2026
Beads task: `goon-game-weighted-objects`
## Contract
`IWeightedObject` is the only pressure-mass contract. It exposes world geometry,
integer pressure mass, and whether the object may activate a mechanism. The live
player, retained corpse bodies, and authored physical objects implement it;
`PressureMassResolver` knows no concrete object types.
The version-1 level schema now accepts an optional `objects` array with a strict
`type`, `box`, and positive `mass`. Supported production types are `BOX`, `ROCK`,
`BALL`, `ICE_BLOCK`, and `COUNTERWEIGHT`, matching the Beads scope. Existing JSON
without `objects` stays source-compatible. Validation, deterministic SVG/TSV
preview, Canvas rendering, collision/push physics, moving-platform support,
automation and performance accounting all consume the same definitions.
Active-session save version 4 stores object type, geometry, velocity, mass,
grounding and moving-platform support. V1–v3 decode with an empty runtime object
list; levels from those versions had no authored physical objects, so restore
uses the level's initial definitions without inventing state.
## Teaching puzzle
Level 17, `The Whole Weight`, places Box(1), Rock(2), Ball(1), and IceBlock(2) on
a pressure plate. Their total 6 cannot open the mass-9 door. The player must push
Counterweight(3) onto the plate, release input to prove object-only retention,
then jump across the weighted row and pass the open door.
## TDD and acceptance evidence
- RED: missing `IWeightedObject`/resolver and all JSON object symbols (28 compile
errors); GREEN: shared pure mass aggregation and five parsed types.
- RED: missing runtime/save-v4 symbols (13 compile errors); GREEN: deterministic
state, corpse interface parity, v4 round-trip and v1–v3 migrations.
- RED: catalog expected 17 but contained 16; GREEN: validator `17/17` and bounded
two-page pagination.
- RED: Android scenario class absent; GREEN: compiled production-input route.
- RED: preview lacked object geometry; GREEN: byte-stable SVG and TSV include
object type and mass.
- Focused push/door/save integration: `1/1` GREEN in `9.128 s`.
- Level 17 intended route: `3/3` GREEN in `31.119 s`, exact zero deaths/bodies.
- Focused player/corpse/object pressure and restore matrix: `8/8` GREEN in
`315.926 s`.
- Clean gate: app JVM `108/108`, content-tool JVM `2/2`, validator `17/17`,
lint, app APK and test APK GREEN.
- Final exact-candidate unfiltered Android matrix: `48/48` GREEN in
`2,223.695 s`, with zero failures and zero skipped tests.
- Debug APK ZIP/signature: GREEN; packaged level JSON count: `17`.
- Debug APK SHA-256:
`141eecd3605d5cc5ab8ac20b52b666650b672c0723e2a3f8ea2187e75adca002`.
+1 -1
View File
@@ -65,7 +65,7 @@ Snapshot на 19 августа 2026 года:
| 3 | 5 | 0 | | 3 | 5 | 0 |
| 4 | 4 | 0 | | 4 | 4 | 0 |
| 5 | 15 | 0 | | 5 | 15 | 0 |
| 6 | 18 | 10 | | 6 | 18 | 8 |
| 7 | 9 | 9 | | 7 | 9 | 9 |
| 8 | 1 | 1 | | 8 | 1 | 1 |
| 9 | 1 | 1 | | 9 | 1 | 1 |
@@ -72,6 +72,10 @@ public final class LevelContentTool {
for (int i = 0; i < level.platforms.size(); i++) { for (int i = 0; i < level.platforms.size(); i++) {
rect(value, "platform", i, level.platforms.get(i).box, "#64748b"); rect(value, "platform", i, level.platforms.get(i).box, "#64748b");
} }
for (int i = 0; i < level.objects.size(); i++) {
LevelDefinition.PhysicalObject item = level.objects.get(i);
rect(value, "object-" + item.type, i, item.box, "#f59e0b");
}
for (int i = 0; i < level.hazards.size(); i++) { for (int i = 0; i < level.hazards.size(); i++) {
rect(value, "hazard-" + level.hazards.get(i).type, i, rect(value, "hazard-" + level.hazards.get(i).type, i,
level.hazards.get(i).box, "#ef4444"); level.hazards.get(i).box, "#ef4444");
@@ -108,6 +112,11 @@ public final class LevelContentTool {
row(value, level.number, "platform", i, item.isMoving() ? "MOVING" : "STATIC", row(value, level.number, "platform", i, item.isMoving() ? "MOVING" : "STATIC",
item.box, item.travelX, item.travelY, item.controlId); item.box, item.travelX, item.travelY, item.controlId);
} }
for (int i = 0; i < level.objects.size(); i++) {
LevelDefinition.PhysicalObject item = level.objects.get(i);
row(value, level.number, "object", i, item.type.name(), item.box,
0f, 0f, "mass=" + item.mass);
}
for (int i = 0; i < level.hazards.size(); i++) { for (int i = 0; i < level.hazards.size(); i++) {
LevelDefinition.Hazard item = level.hazards.get(i); LevelDefinition.Hazard item = level.hazards.get(i);
row(value, level.number, "hazard", i, item.type.name(), item.box, row(value, level.number, "hazard", i, item.type.name(), item.box,
@@ -10,6 +10,7 @@ public final class LevelDefinition {
SPIKES, SAW, FREEZER, ELECTRIC, PIT, CRUSHER, FIRE, PROJECTILE, EXPLOSION SPIKES, SAW, FREEZER, ELECTRIC, PIT, CRUSHER, FIRE, PROJECTILE, EXPLOSION
} }
public enum SwitchType { PRESSURE, TOGGLE, TIMED } public enum SwitchType { PRESSURE, TOGGLE, TIMED }
public enum PhysicalObjectType { BOX, ROCK, BALL, ICE_BLOCK, COUNTERWEIGHT }
public static final class Box { public static final class Box {
public final float x; public final float x;
@@ -115,6 +116,18 @@ public final class LevelDefinition {
} }
} }
public static final class PhysicalObject {
public final PhysicalObjectType type;
public final Box box;
public final int mass;
public PhysicalObject(PhysicalObjectType type, Box box, int mass) {
this.type = type;
this.box = box;
this.mass = mass;
}
}
public final int number; public final int number;
public final int formatVersion; public final int formatVersion;
public final int physicsVersion; public final int physicsVersion;
@@ -129,6 +142,7 @@ public final class LevelDefinition {
public final Box goal; public final Box goal;
public final int targetDeaths; public final int targetDeaths;
public final int targetSeconds; public final int targetSeconds;
public final List<PhysicalObject> objects;
public final List<Platform> platforms; public final List<Platform> platforms;
public final List<Hazard> hazards; public final List<Hazard> hazards;
public final List<Switch> switches; public final List<Switch> switches;
@@ -151,7 +165,7 @@ public final class LevelDefinition {
) { ) {
this(1, 1, number, title, hint, title, hint, worldWidth, 1080f, this(1, 1, number, title, hint, title, hint, worldWidth, 1080f,
spawnX, spawnY, goal, targetDeaths, targetSeconds, spawnX, spawnY, goal, targetDeaths, targetSeconds,
platforms, hazards, switches, doors); Collections.emptyList(), platforms, hazards, switches, doors);
} }
public LevelDefinition( public LevelDefinition(
@@ -173,6 +187,32 @@ public final class LevelDefinition {
List<Hazard> hazards, List<Hazard> hazards,
List<Switch> switches, List<Switch> switches,
List<Door> doors List<Door> doors
) {
this(formatVersion, physicsVersion, number, title, hint, titleRu, hintRu,
worldWidth, worldHeight, spawnX, spawnY, goal, targetDeaths,
targetSeconds, Collections.emptyList(), platforms, hazards, switches, doors);
}
public LevelDefinition(
int formatVersion,
int physicsVersion,
int number,
String title,
String hint,
String titleRu,
String hintRu,
float worldWidth,
float worldHeight,
float spawnX,
float spawnY,
Box goal,
int targetDeaths,
int targetSeconds,
List<PhysicalObject> objects,
List<Platform> platforms,
List<Hazard> hazards,
List<Switch> switches,
List<Door> doors
) { ) {
this.formatVersion = formatVersion; this.formatVersion = formatVersion;
this.physicsVersion = physicsVersion; this.physicsVersion = physicsVersion;
@@ -188,6 +228,7 @@ public final class LevelDefinition {
this.goal = goal; this.goal = goal;
this.targetDeaths = targetDeaths; this.targetDeaths = targetDeaths;
this.targetSeconds = targetSeconds; this.targetSeconds = targetSeconds;
this.objects = Collections.unmodifiableList(objects);
this.platforms = Collections.unmodifiableList(platforms); this.platforms = Collections.unmodifiableList(platforms);
this.hazards = Collections.unmodifiableList(hazards); this.hazards = Collections.unmodifiableList(hazards);
this.switches = Collections.unmodifiableList(switches); this.switches = Collections.unmodifiableList(switches);
@@ -37,6 +37,7 @@ public final class LevelJsonLoader {
optionalText(value, "controlId", path))); optionalText(value, "controlId", path)));
} }
List<LevelDefinition.PhysicalObject> objects = physicalObjects(root, source);
List<LevelDefinition.Hazard> hazards = hazards(root, source); List<LevelDefinition.Hazard> hazards = hazards(root, source);
List<LevelDefinition.Switch> switches = switches(root, source); List<LevelDefinition.Switch> switches = switches(root, source);
List<LevelDefinition.Door> doors = doors(root, source); List<LevelDefinition.Door> doors = doors(root, source);
@@ -56,12 +57,29 @@ public final class LevelJsonLoader {
box(required(root, "goal", source), source + ".goal"), box(required(root, "goal", source), source + ".goal"),
integer(targets, "deaths", source + ".targets"), integer(targets, "deaths", source + ".targets"),
integer(targets, "seconds", source + ".targets"), integer(targets, "seconds", source + ".targets"),
objects,
platforms, platforms,
hazards, hazards,
switches, switches,
doors), source); doors), source);
} }
private static List<LevelDefinition.PhysicalObject> physicalObjects(
Map<String, Object> root, String source) {
if (!root.containsKey("objects")) return Collections.emptyList();
List<Object> values = array(root.get("objects"), source + ".objects");
List<LevelDefinition.PhysicalObject> result = new ArrayList<>();
for (int i = 0; i < values.size(); i++) {
String path = source + ".objects[" + i + "]";
Map<String, Object> value = object(values.get(i), path);
result.add(new LevelDefinition.PhysicalObject(
physicalObjectType(text(value, "type", path), path + ".type"),
box(required(value, "box", path), path + ".box"),
integer(value, "mass", path)));
}
return result;
}
static List<String> loadIndex(Reader reader, String sourceName) { static List<String> loadIndex(Reader reader, String sourceName) {
String source = sourceName == null ? "<levels-index>" : sourceName; String source = sourceName == null ? "<levels-index>" : sourceName;
Map<String, Object> root = object(new JsonValueParser(reader, source).parse(), source); Map<String, Object> root = object(new JsonValueParser(reader, source).parse(), source);
@@ -171,6 +189,15 @@ public final class LevelJsonLoader {
} }
} }
private static LevelDefinition.PhysicalObjectType physicalObjectType(
String value, String path) {
try {
return LevelDefinition.PhysicalObjectType.valueOf(value);
} catch (IllegalArgumentException error) {
throw invalid(path, "has unknown physical object type '" + value + "'");
}
}
private static void requireEmptyArray(Map<String, Object> parent, String key, String path) { private static void requireEmptyArray(Map<String, Object> parent, String key, String path) {
List<Object> values = array(required(parent, key, path), path + "." + key); List<Object> values = array(required(parent, key, path), path + "." + key);
if (!values.isEmpty()) { if (!values.isEmpty()) {
@@ -25,6 +25,13 @@ public final class LevelValidator {
if (level.platforms.isEmpty()) { if (level.platforms.isEmpty()) {
throw invalid(source + ".platforms", "must contain at least one platform"); throw invalid(source + ".platforms", "must contain at least one platform");
} }
for (int i = 0; i < level.objects.size(); i++) {
LevelDefinition.PhysicalObject object = level.objects.get(i);
String path = source + ".objects[" + i + "]";
if (object.type == null) throw invalid(path + ".type", "must be known");
box(object.box, level, path + ".box");
if (object.mass <= 0) throw invalid(path + ".mass", "must be positive");
}
for (int i = 0; i < level.platforms.size(); i++) { for (int i = 0; i < level.platforms.size(); i++) {
LevelDefinition.Platform platform = level.platforms.get(i); LevelDefinition.Platform platform = level.platforms.get(i);
String path = source + ".platforms[" + i + "]"; String path = source + ".platforms[" + i + "]";
@@ -1 +1 @@
{"formatVersion":1,"physicsVersion":1,"levels":["level-01.json","level-02.json","level-03.json","level-04.json","level-05.json","level-06.json","level-07.json","level-08.json","level-09.json","level-10.json","level-11.json","level-12.json","level-13.json","level-14.json","level-15.json","level-16.json"]} {"formatVersion":1,"physicsVersion":1,"levels":["level-01.json","level-02.json","level-03.json","level-04.json","level-05.json","level-06.json","level-07.json","level-08.json","level-09.json","level-10.json","level-11.json","level-12.json","level-13.json","level-14.json","level-15.json","level-16.json","level-17.json"]}
@@ -0,0 +1,17 @@
{
"formatVersion":1,"physicsVersion":1,"number":17,
"copy":{"en":{"title":"The Whole Weight","hint":"Push the counterweight onto the plate. Every object adds its marked weight."},"ru":{"title":"Полный вес","hint":"Толкните противовес на плиту. Каждый объект добавляет указанный вес."}},
"world":{"width":2500,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2300,"y":770,"width":86,"height":110},"targets":{"deaths":0,"seconds":45},
"objects":[
{"type":"COUNTERWEIGHT","box":{"x":650,"y":780,"width":90,"height":100},"mass":3},
{"type":"BOX","box":{"x":1250,"y":800,"width":80,"height":80},"mass":1},
{"type":"ROCK","box":{"x":1360,"y":820,"width":60,"height":60},"mass":2},
{"type":"BALL","box":{"x":1450,"y":820,"width":60,"height":60},"mass":1},
{"type":"ICE_BLOCK","box":{"x":1530,"y":800,"width":80,"height":80},"mass":2}
],
"platforms":[{"box":{"x":0,"y":880,"width":2500,"height":200}}],
"hazards":[],
"switches":[{"type":"PRESSURE","box":{"x":1000,"y":860,"width":650,"height":20},"signalId":"weight-gate","durationSeconds":0,"requiredMass":9}],
"doors":[{"box":{"x":1750,"y":560,"width":82,"height":320},"requiredSignals":["weight-gate"]}]
}
@@ -60,6 +60,10 @@ public final class LevelContentToolTest {
assertTrue(svg, svg.contains("viewBox=\"0 0 1920 1080\"")); assertTrue(svg, svg.contains("viewBox=\"0 0 1920 1080\""));
assertTrue(svg, svg.contains("data-kind=\"spawn\"")); assertTrue(svg, svg.contains("data-kind=\"spawn\""));
assertTrue(svg, svg.contains("data-kind=\"goal\"")); assertTrue(svg, svg.contains("data-kind=\"goal\""));
assertTrue(svg, svg.contains("data-kind=\"object-COUNTERWEIGHT\""));
String coordinates = Files.readString(first.resolve("catalog.tsv"));
assertTrue(coordinates, coordinates.contains("1\tobject\t0\tCOUNTERWEIGHT"));
assertTrue(coordinates, coordinates.contains("mass=3"));
} }
private static void writeFixture(Path levels) throws Exception { private static void writeFixture(Path levels) throws Exception {
@@ -76,6 +80,9 @@ public final class LevelContentToolTest {
+ "\"spawn\":{\"x\":130,\"y\":760}," + "\"spawn\":{\"x\":130,\"y\":760},"
+ "\"goal\":{\"x\":1690,\"y\":770,\"width\":86,\"height\":110}," + "\"goal\":{\"x\":1690,\"y\":770,\"width\":86,\"height\":110},"
+ "\"targets\":{\"deaths\":0,\"seconds\":30}," + "\"targets\":{\"deaths\":0,\"seconds\":30},"
+ "\"objects\":[{\"type\":\"COUNTERWEIGHT\","
+ "\"box\":{\"x\":700,\"y\":780,\"width\":80,\"height\":100},"
+ "\"mass\":3}],"
+ "\"platforms\":[{\"box\":{\"x\":0,\"y\":880,\"width\":1920,\"height\":200}}]," + "\"platforms\":[{\"box\":{\"x\":0,\"y\":880,\"width\":1920,\"height\":200}}],"
+ "\"hazards\":[],\"switches\":[],\"doors\":[]}"; + "\"hazards\":[],\"switches\":[],\"doors\":[]}";
} }