Добавить общую систему весовых объектов
This commit is contained in:
+2
-2
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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 дней
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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\":[]}";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user