Добавить production-каталог опасностей

This commit is contained in:
2026-08-19 05:30:56 +03:00
parent cc7f487cf7
commit dc2ebcbf8c
33 changed files with 1160 additions and 37 deletions
+2 -2
View File
@@ -60,7 +60,7 @@
{"_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":"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-e04","title":"EPIC-04 Hazards","description":"Каталог опасностей и универсальный контракт источника смерти. Разделы 5, 10, 38.","acceptance_criteria":"Опасности data-driven, задокументированы и проходят Android/QA проверки.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","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-18T21:03:11Z","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}
{"_type":"issue","id":"goon-game-milestone-production","title":"Milestone: Production","description":"Массовое производство уровней параллельными потоками после утверждения vertical slice.","acceptance_criteria":"Полный content matrix реализован и прошёл content pipeline.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:02Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","metadata":{"stages":6,"primary_stage":6},"labels":["milestone","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-milestone-production","depends_on_id":"goon-game-content-worlds","type":"blocks","created_at":"2026-08-18T00:44:25Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-production","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:56Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-production","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:44:24Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"goon-game-milestone-production","title":"Milestone: Production","description":"Массовое производство уровней параллельными потоками после утверждения vertical slice.","acceptance_criteria":"Полный content matrix реализован и прошёл content pipeline.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:02Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","metadata":{"stages":6,"primary_stage":6},"labels":["milestone","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-milestone-production","depends_on_id":"goon-game-content-worlds","type":"blocks","created_at":"2026-08-18T00:44:25Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-production","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:56Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-production","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:44:24Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
@@ -69,7 +69,7 @@
{"_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 одинаково работает с персонажем, телом и объектами.","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-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":"in_progress","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-19T01:07:11Z","started_at":"2026-08-19T01:07:11Z","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 механики.","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:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:27Z","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 механики.","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:29Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:27Z","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}
{"_type":"issue","id":"goon-game-e11","title":"EPIC-11 Art","description":"Самостоятельное художественное направление, модульные персонажи и анимации. Разделы 26–28.","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:05Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:44Z","metadata":{"stages":"5,6","primary_stage":6},"labels":["art","epic","primary-stage-6","stage-5","stage-6"],"dependencies":[{"issue_id":"goon-game-e11","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:54Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e11","title":"EPIC-11 Art","description":"Самостоятельное художественное направление, модульные персонажи и анимации. Разделы 26–28.","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:05Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:44Z","metadata":{"stages":"5,6","primary_stage":6},"labels":["art","epic","primary-stage-6","stage-5","stage-6"],"dependencies":[{"issue_id":"goon-game-e11","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:54Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
+6 -3
View File
@@ -107,10 +107,13 @@ invented session data in `docs/STAGE5_HUMAN_PLAYTEST_RESULT.md`. Stage 5 is acce
Stage 6 production work has started. Its three completed foundation slices add a Stage 6 production work has started. Its three completed foundation slices add a
deterministic character state machine, a bounded `CorpseManager`, and an deterministic character state machine, a bounded `CorpseManager`, and an
event-driven `PuzzleSystem` with explicit signal and door transitions. Scope and event-driven `PuzzleSystem` with explicit signal and door transitions. The first
final evidence are in `docs/STAGE6_CHARACTER_STATES.md`, mechanics slice adds data-driven crusher, fire, projectile and explosion hazards,
teaching levels 11–14, dynamic level pagination and production-input routes.
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
unfiltered gate is `89 JVM / 38 Android`. hazard evidence is in `docs/STAGE6_PRODUCTION_HAZARDS.md`. The latest unfiltered
gate is `99 JVM / 43 Android`.
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:
@@ -0,0 +1,64 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.os.SystemClock;
import android.view.MotionEvent;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.List;
@RunWith(AndroidJUnit4.class)
public final class LevelPaginationTest {
@Test
public void nextAndPreviousControlsExposeEveryAuthoredLevel() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
view.setLanguageForAutomation(GameLanguage.EN);
assertEquals(0, view.levelPageIndexForAutomation());
assertEquals(2, view.levelPageCountForAutomation());
assertTrue(view.visibleLocalizedTextForAutomation().contains("The Volunteer"));
assertFalse(view.visibleLocalizedTextForAutomation().contains("Steam Charge"));
});
tap(activity, 1_730f, 965f);
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
List<String> visible = view.visibleLocalizedTextForAutomation();
assertEquals(1, view.levelPageIndexForAutomation());
assertTrue(visible.contains("Steam Charge"));
assertFalse(visible.contains("The Volunteer"));
assertTrue(visible.contains("Page 2 of 2"));
});
tap(activity, 190f, 965f);
activity.onActivity(current ->
assertEquals(0, current.gameViewForAutomation().levelPageIndexForAutomation()));
}
}
private static void tap(ActivityScenario<MainActivity> activity, float canvasX, float canvasY) {
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
float scale = Math.min(view.getWidth() / 1_920f, view.getHeight() / 1_080f);
float x = (view.getWidth() - 1_920f * scale) * 0.5f + canvasX * scale;
float y = (view.getHeight() - 1_080f * scale) * 0.5f + canvasY * scale;
long now = SystemClock.uptimeMillis();
MotionEvent down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0);
view.dispatchTouchEvent(down);
down.recycle();
MotionEvent up = MotionEvent.obtain(now, now, MotionEvent.ACTION_UP, x, y, 0);
view.dispatchTouchEvent(up);
up.recycle();
});
}
}
@@ -0,0 +1,94 @@
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 ProductionHazardPlaythroughTest {
private static final int CONSECUTIVE_RUNS = 3;
@Test public void crusherLevelCompletesThreeTimes() {
prove(ProductionHazardScenarios.levelEleven(), 35_000L, false);
}
@Test public void fireLevelCompletesThreeTimes() {
prove(ProductionHazardScenarios.levelTwelve(), 35_000L, false);
}
@Test public void projectileLevelCompletesThreeTimes() {
prove(ProductionHazardScenarios.levelThirteen(), 40_000L, false);
}
@Test public void explosionDeliversBodyAndOpensGateThreeTimes() {
prove(ProductionHazardScenarios.levelFourteen(), 45_000L, true);
}
private static void prove(LevelPlaytestBot.Scenario scenario, long budget,
boolean requiresDoor) {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
AndroidDriver driver = AndroidDriver.from(activity);
for (int run = 1; run <= CONSECUTIVE_RUNS; run++) {
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, budget);
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
assertEquals("run=" + run + "; teaching route uses exactly one volunteer",
1, result.deaths());
assertEquals("run=" + run + "; sacrificed body remains in the level",
1, result.corpseCount());
if (requiresDoor) {
assertTrue("run=" + run + "; blast-delivered body must open the gate",
result.doorOpened());
}
}
}
}
private static final class AndroidDriver implements LevelPlaytestBot.Driver {
private final Instrumentation instrumentation;
private final GameView gameView;
private AndroidDriver(Instrumentation instrumentation, GameView gameView) {
this.instrumentation = instrumentation;
this.gameView = gameView;
}
static AndroidDriver from(ActivityScenario<MainActivity> activity) {
GameView[] view = new GameView[1];
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
return new AndroidDriver(InstrumentationRegistry.getInstrumentation(), view[0]);
}
@Override public void startLevel(int oneBasedLevelNumber) {
instrumentation.runOnMainSync(
() -> gameView.startLevelForAutomation(oneBasedLevelNumber));
}
@Override public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
instrumentation.runOnMainSync(
() -> gameView.setAutomationInput(moveLeft, moveRight, jump));
}
@Override public GameView.AutomationSnapshot snapshot() {
GameView.AutomationSnapshot[] snapshot = new GameView.AutomationSnapshot[1];
instrumentation.runOnMainSync(() -> snapshot[0] = gameView.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("Production-hazard bot interrupted", interrupted);
}
}
}
}
@@ -0,0 +1,143 @@
package com.projectsacrifice.game;
/** Intended routes for the first four data-driven production hazards. */
final class ProductionHazardScenarios {
private ProductionHazardScenarios() {}
static LevelPlaytestBot.Scenario levelEleven() {
return LevelPlaytestBot.Scenario.builder(11, "Level 11 - crusher rhythm")
.step("Sacrifice one volunteer to the press", 4_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Approach the retained body", 2_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
.step("Observe the press close for a fresh cycle", 4_000L,
LevelPlaytestBot.Input.none(),
GameView.AutomationSnapshot::firstHazardActive)
.step("Observe the press resting", 4_000L, LevelPlaytestBot.Input.none(),
snapshot -> !snapshot.firstHazardActive())
.step("Jump through the resting press", 2_500L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 900f))
.step("Reach the relic", 6_000L, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed)
.build();
}
static LevelPlaytestBot.Scenario levelTwelve() {
return LevelPlaytestBot.Scenario.builder(12, "Level 12 - blue fire")
.step("Sacrifice one volunteer to the flame", 4_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Approach the blue flame", 2_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
LevelPlaytestBot.groundedAliveAtOrBeyond(535f)))
.step("Take off before the flame", 1_000L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround()
&& snapshot.playerY() <= 700f))
.step("Clear the flame", 2_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 850f))
.step("Reach the relic", 6_000L, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed)
.build();
}
static LevelPlaytestBot.Scenario levelThirteen() {
return LevelPlaytestBot.Scenario.builder(13, "Level 13 - clockwork projectile")
.step("Read the bolt by sacrificing once", 5_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Approach the firing line", 2_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
LevelPlaytestBot.groundedAliveAtOrBeyond(600f)))
.step("Wait for the jump window", 4_000L, LevelPlaytestBot.Input.none(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.firstHazardX() >= 850f
&& snapshot.firstHazardX() <= 930f))
.step("Jump through the bolt line", 1_500L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerY() <= 690f))
.step("Clear the firing line", 2_500L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 1_050f))
.step("Wait for the returning bolt", 4_000L, LevelPlaytestBot.Input.none(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.firstHazardX() >= 1_350f
&& snapshot.firstHazardX() <= 1_450f))
.step("Jump through the returning bolt", 1_500L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerY() <= 690f))
.step("Clear the second firing line", 2_000L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 1_600f))
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
GameView.AutomationSnapshot::completed))
.build();
}
static LevelPlaytestBot.Scenario levelFourteen() {
return LevelPlaytestBot.Scenario.builder(14, "Level 14 - explosion delivery")
.step("Give one volunteer to the blast", 4_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
.step("Observe the delivered body open the gate", 5_000L,
LevelPlaytestBot.Input.none(),
GameView.AutomationSnapshot::allDoorsOpen)
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Approach the steam charge", 2_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
LevelPlaytestBot.groundedAliveAtOrBeyond(390f)))
.step("Observe the charge rebuild", 4_000L,
LevelPlaytestBot.Input.none(),
GameView.AutomationSnapshot::firstHazardActive)
.step("Observe the safe blast rest", 4_000L,
LevelPlaytestBot.Input.none(),
snapshot -> !snapshot.firstHazardActive())
.step("Take off before the blast", 1_000L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround()
&& snapshot.playerY() <= 690f))
.step("Clear the blast", 2_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 820f))
.step("Approach the delivered body", 1_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 880f)
.step("Jump over the delivered body", 2_000L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 1_200f))
.step("Pass the opened gate", 4_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_550f)
.step("Reach the relic", 5_000L, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed)
.build();
}
}
@@ -8,6 +8,10 @@ public enum AudioCue {
SWITCH(Channel.EFFECTS, 3), SWITCH(Channel.EFFECTS, 3),
DOOR(Channel.EFFECTS, 2), DOOR(Channel.EFFECTS, 2),
DEATH_IMPACT(Channel.EFFECTS, 5), DEATH_IMPACT(Channel.EFFECTS, 5),
CRUSHER_IMPACT(Channel.EFFECTS, 3),
FIRE_BURST(Channel.EFFECTS, 3),
PROJECTILE_HIT(Channel.EFFECTS, 3),
EXPLOSION_BLAST(Channel.EFFECTS, 3),
DEATH_VOICE(Channel.VOICE, 4), DEATH_VOICE(Channel.VOICE, 4),
RESPAWN_VOICE(Channel.VOICE, 3), RESPAWN_VOICE(Channel.VOICE, 3),
GOAL_VOICE(Channel.VOICE, 3), GOAL_VOICE(Channel.VOICE, 3),
@@ -40,7 +40,7 @@ public final class DeterministicInputTape {
private final ArrayList<Command> commands = new ArrayList<>(); private final ArrayList<Command> commands = new ArrayList<>();
private Builder(int levelNumber) { private Builder(int levelNumber) {
if (levelNumber < 1 || levelNumber > 10) { if (levelNumber < 1 || levelNumber > LevelCatalog.all().size()) {
throw new IllegalArgumentException("Unknown level: " + levelNumber); throw new IllegalArgumentException("Unknown level: " + levelNumber);
} }
this.levelNumber = levelNumber; this.levelNumber = levelNumber;
@@ -22,8 +22,10 @@ public final class GameStrings {
public String appContentDescription() { public String appContentDescription() {
return russian() return russian()
? "Project Sacrifice. Платформенная головоломка из десяти уровней." ? "Project Sacrifice. Платформенная головоломка, уровней: "
: "Project Sacrifice. Ten-level puzzle platform game."; + LevelCatalog.all().size() + "."
: "Project Sacrifice. Puzzle platform game with "
+ LevelCatalog.all().size() + " levels.";
} }
public String projectTitle() { return "PROJECT SACRIFICE"; } public String projectTitle() { return "PROJECT SACRIFICE"; }
@@ -43,6 +45,14 @@ public final class GameStrings {
return russian() ? "Настройки" : "Settings"; return russian() ? "Настройки" : "Settings";
} }
public String previousPage() { return russian() ? "Назад" : "Previous"; }
public String nextPage() { return russian() ? "Далее" : "Next"; }
public String pageIndicator(int currentPage, int totalPages) {
return russian()
? "Страница " + currentPage + " из " + totalPages
: "Page " + currentPage + " of " + totalPages;
}
public String settingsTitle() { return russian() ? "НАСТРОЙКИ" : "SETTINGS"; } public String settingsTitle() { return russian() ? "НАСТРОЙКИ" : "SETTINGS"; }
public String settingsInstruction() { public String settingsInstruction() {
return russian() ? "Нажмите на строку, чтобы изменить. Всё сохраняется автоматически." return russian() ? "Нажмите на строку, чтобы изменить. Всё сохраняется автоматически."
@@ -43,9 +43,12 @@ public final class GameView extends View implements Runnable {
private static final float JUMP_BUFFER_SECONDS = PHYSICS.jumpBufferSeconds(); private static final float JUMP_BUFFER_SECONDS = PHYSICS.jumpBufferSeconds();
private static final float RESPAWN_SECONDS = PHYSICS.respawnSeconds(); private static final float RESPAWN_SECONDS = PHYSICS.respawnSeconds();
private static final int MAX_CORPSES = PHYSICS.maximumBodies(); private static final int MAX_CORPSES = PHYSICS.maximumBodies();
private static final int LEVELS_PER_PAGE = 10;
private enum Screen { LEVELS, SETTINGS, PLAYING, COMPLETE } private enum Screen { LEVELS, SETTINGS, PLAYING, COMPLETE }
private enum DeathType { SPIKE, SAW, FREEZE, ELECTRIC, PIT } private enum DeathType {
SPIKE, SAW, FREEZE, ELECTRIC, PIT, CRUSH, FIRE, PROJECTILE, EXPLOSION
}
public static final class AutomationSnapshot { public static final class AutomationSnapshot {
private final boolean completed; private final boolean completed;
@@ -69,6 +72,9 @@ public final class GameView extends View implements Runnable {
private final boolean playerOnGround; private final boolean playerOnGround;
private final float playerVelocityX; private final float playerVelocityX;
private final float playerVelocityY; private final float playerVelocityY;
private final boolean firstHazardActive;
private final float firstHazardX;
private final float firstHazardY;
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,
@@ -137,6 +143,26 @@ public final class GameView extends View implements Runnable {
boolean firstMovingPlatformMovingDown, float firstCorpseX, boolean firstMovingPlatformMovingDown, float firstCorpseX,
boolean playerOnGround, float playerVelocityX, boolean playerOnGround, float playerVelocityX,
float playerVelocityY) { float playerVelocityY) {
this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber,
elapsedMilliseconds, playerX, playerY, deaths, corpseCount,
firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform,
firstMovingPlatformPowered, firstMovingPlatformMovingRight,
firstMovingPlatformMovingDown, firstCorpseX, playerOnGround,
playerVelocityX, playerVelocityY, false, Float.NaN, Float.NaN);
}
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) {
this.completed = completed; this.completed = completed;
this.playerAlive = playerAlive; this.playerAlive = playerAlive;
this.allDoorsOpen = allDoorsOpen; this.allDoorsOpen = allDoorsOpen;
@@ -158,6 +184,9 @@ public final class GameView extends View implements Runnable {
this.playerOnGround = playerOnGround; this.playerOnGround = playerOnGround;
this.playerVelocityX = playerVelocityX; this.playerVelocityX = playerVelocityX;
this.playerVelocityY = playerVelocityY; this.playerVelocityY = playerVelocityY;
this.firstHazardActive = firstHazardActive;
this.firstHazardX = firstHazardX;
this.firstHazardY = firstHazardY;
} }
public boolean completed() { return completed; } public boolean completed() { return completed; }
@@ -181,6 +210,9 @@ public final class GameView extends View implements Runnable {
public boolean playerOnGround() { return playerOnGround; } public boolean playerOnGround() { return playerOnGround; }
public float playerVelocityX() { return playerVelocityX; } public float playerVelocityX() { return playerVelocityX; }
public float playerVelocityY() { return playerVelocityY; } public float playerVelocityY() { return playerVelocityY; }
public boolean firstHazardActive() { return firstHazardActive; }
public float firstHazardX() { return firstHazardX; }
public float firstHazardY() { return firstHazardY; }
} }
private static final class Player { private static final class Player {
@@ -229,6 +261,7 @@ public final class GameView extends View implements Runnable {
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<PlatformState> platformStates = new ArrayList<>(); private final ArrayList<PlatformState> platformStates = 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<>();
private final Map<LevelDefinition.Door, DoorController> doorControllers = private final Map<LevelDefinition.Door, DoorController> doorControllers =
new IdentityHashMap<>(); new IdentityHashMap<>();
@@ -240,6 +273,8 @@ public final class GameView extends View implements Runnable {
private final CameraController cameraController = private final CameraController cameraController =
new CameraController(VIEW_WIDTH, VIEW_HEIGHT); new CameraController(VIEW_WIDTH, VIEW_HEIGHT);
private final PerformanceMonitor performanceMonitor = new PerformanceMonitor(240); private final PerformanceMonitor performanceMonitor = new PerformanceMonitor(240);
private final LevelPage levelPage =
new LevelPage(LevelCatalog.all().size(), LEVELS_PER_PAGE);
private Screen screen = Screen.LEVELS; private Screen screen = Screen.LEVELS;
private LevelDefinition level; private LevelDefinition level;
@@ -394,12 +429,16 @@ public final class GameView extends View implements Runnable {
deriveControls(); deriveControls();
corpseManager.reset(); corpseManager.reset();
platformStates.clear(); platformStates.clear();
hazardRuntimes.clear();
previousSwitchContact.clear(); previousSwitchContact.clear();
configurePuzzleSystem(); configurePuzzleSystem();
completionResult = null; completionResult = null;
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.Hazard item : level.hazards) {
hazardRuntimes.add(new HazardRuntime(item));
}
spawnPlayer(); spawnPlayer();
lastFrameNanos = System.nanoTime(); lastFrameNanos = System.nanoTime();
refreshAccessibilityDescription(true); refreshAccessibilityDescription(true);
@@ -428,6 +467,7 @@ public final class GameView extends View implements Runnable {
private void update(float dt) { private void update(float dt) {
levelElapsed += dt; levelElapsed += dt;
audioEngine.updateMusic(levelElapsed, level.number); audioEngine.updateMusic(levelElapsed, level.number);
for (HazardRuntime runtime : hazardRuntimes) runtime.update(dt);
updateSignals(dt); updateSignals(dt);
updatePlatforms(dt); updatePlatforms(dt);
@@ -769,14 +809,22 @@ public final class GameView extends View implements Runnable {
} }
private void checkHazards() { private void checkHazards() {
for (LevelDefinition.Hazard hazard : level.hazards) { for (int index = 0; index < level.hazards.size(); index++) {
if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, hazard.box)) continue; LevelDefinition.Hazard hazard = level.hazards.get(index);
HazardRuntime runtime = hazardRuntimes.get(index);
if (!runtime.active()) continue;
if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT,
runtime.currentBox())) continue;
switch (hazard.type) { switch (hazard.type) {
case SPIKES: kill(DeathType.SPIKE, hazard); return; case SPIKES: kill(DeathType.SPIKE, hazard); return;
case SAW: kill(DeathType.SAW, hazard); return; case SAW: kill(DeathType.SAW, hazard); return;
case FREEZER: kill(DeathType.FREEZE, hazard); return; case FREEZER: kill(DeathType.FREEZE, hazard); return;
case ELECTRIC: kill(DeathType.ELECTRIC, hazard); return; case ELECTRIC: kill(DeathType.ELECTRIC, hazard); return;
case PIT: kill(DeathType.PIT, hazard); return; case PIT: kill(DeathType.PIT, hazard); return;
case CRUSHER: kill(DeathType.CRUSH, hazard); return;
case FIRE: kill(DeathType.FIRE, hazard); return;
case PROJECTILE: kill(DeathType.PROJECTILE, hazard); return;
case EXPLOSION: kill(DeathType.EXPLOSION, hazard); return;
} }
} }
} }
@@ -792,7 +840,7 @@ public final class GameView extends View implements Runnable {
respawnTimer = RESPAWN_SECONDS; respawnTimer = RESPAWN_SECONDS;
emitHaptic(Build.VERSION.SDK_INT >= 27 emitHaptic(Build.VERSION.SDK_INT >= 27
? HapticFeedbackConstants.LONG_PRESS : HapticFeedbackConstants.KEYBOARD_TAP); ? HapticFeedbackConstants.LONG_PRESS : HapticFeedbackConstants.KEYBOARD_TAP);
audioEngine.play(AudioCue.DEATH_IMPACT); audioEngine.play(hazardAudioCue(source));
audioEngine.play(AudioCue.DEATH_VOICE); audioEngine.play(AudioCue.DEATH_VOICE);
cameraController.impact(type == DeathType.PIT ? 12f : 24f); cameraController.impact(type == DeathType.PIT ? 12f : 24f);
@@ -824,6 +872,22 @@ public final class GameView extends View implements Runnable {
} }
} }
private static AudioCue hazardAudioCue(LevelDefinition.Hazard source) {
if (source != null && "crusher-impact".equals(source.audioCue)) {
return AudioCue.CRUSHER_IMPACT;
}
if (source != null && "fire-burst".equals(source.audioCue)) {
return AudioCue.FIRE_BURST;
}
if (source != null && "projectile-hit".equals(source.audioCue)) {
return AudioCue.PROJECTILE_HIT;
}
if (source != null && "explosion-blast".equals(source.audioCue)) {
return AudioCue.EXPLOSION_BLAST;
}
return AudioCue.DEATH_IMPACT;
}
private void checkGoal() { private void checkGoal() {
if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, level.goal)) return; if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, level.goal)) return;
finishLevel(deaths, (long) (levelElapsed * 1000f)); finishLevel(deaths, (long) (levelElapsed * 1000f));
@@ -1041,8 +1105,9 @@ public final class GameView extends View implements Runnable {
} }
private void drawHazards(Canvas canvas) { private void drawHazards(Canvas canvas) {
for (LevelDefinition.Hazard hazard : level.hazards) { for (int index = 0; index < level.hazards.size(); index++) {
RectF box = rect(hazard.box); LevelDefinition.Hazard hazard = level.hazards.get(index);
RectF box = rect(hazardRuntimes.get(index).currentBox());
switch (hazard.type) { switch (hazard.type) {
case SPIKES: case SPIKES:
paint.setColor(Color.rgb(194, 201, 211)); paint.setColor(Color.rgb(194, 201, 211));
@@ -1088,10 +1153,88 @@ public final class GameView extends View implements Runnable {
paint.setColor(Color.argb(80, 132, 87, 170)); paint.setColor(Color.argb(80, 132, 87, 170));
for (float x = box.left + 30; x < box.right; x += 80) canvas.drawOval(new RectF(x, box.top + 35, x + 30, box.top + 80), paint); for (float x = box.left + 30; x < box.right; x += 80) canvas.drawOval(new RectF(x, box.top + 35, x + 30, box.top + 80), paint);
break; break;
case CRUSHER:
drawCrusher(canvas, box, hazardRuntimes.get(index).active(),
hazard.visualStyle);
break;
case FIRE:
drawFire(canvas, box, hazard.visualStyle);
break;
case PROJECTILE:
drawProjectile(canvas, box, hazard.visualStyle);
break;
case EXPLOSION:
drawExplosion(canvas, box, hazardRuntimes.get(index).active(),
hazard.visualStyle);
break;
} }
} }
} }
private void drawCrusher(Canvas canvas, RectF box, boolean active, String visualStyle) {
boolean brass = "brass-press".equals(visualStyle);
paint.setColor(accessibilitySettings.highContrastEnabled()
? Color.BLACK : (brass ? Color.rgb(91, 66, 38) : Color.rgb(72, 76, 88)));
canvas.drawRoundRect(box, 12f, 12f, paint);
float jawHeight = active ? box.height() * 0.48f : box.height() * 0.18f;
paint.setColor(accessibilitySettings.highContrastEnabled()
? Color.WHITE : (brass ? Color.rgb(211, 157, 67) : Color.rgb(174, 180, 192)));
canvas.drawRect(box.left + 8f, box.top + 8f,
box.right - 8f, box.top + jawHeight, paint);
canvas.drawRect(box.left + 8f, box.bottom - jawHeight,
box.right - 8f, box.bottom - 8f, paint);
drawWorldLabel(canvas, active ? "CRUSH" : "WAIT", box.centerX(), box.top - 20f);
}
private void drawFire(Canvas canvas, RectF box, String visualStyle) {
boolean blue = "blue-flame".equals(visualStyle);
paint.setColor(Color.argb(110, blue ? 54 : 236, blue ? 182 : 90,
blue ? 255 : 45));
canvas.drawRoundRect(box, 16f, 16f, paint);
float wave = accessibilitySettings.reducedMotionEnabled()
? 0f : (float) Math.sin(levelElapsed * 7f) * 10f;
paint.setColor(accessibilitySettings.highContrastEnabled()
? Color.WHITE : (blue ? Color.rgb(116, 229, 255) : Color.rgb(255, 190, 64)));
for (float x = box.left + 18f; x < box.right; x += 34f) {
path.reset();
path.moveTo(x - 14f, box.bottom - 6f);
path.lineTo(x + wave * 0.25f, box.top + 12f);
path.lineTo(x + 14f, box.bottom - 6f);
path.close();
canvas.drawPath(path, paint);
}
}
private void drawProjectile(Canvas canvas, RectF box, String visualStyle) {
boolean clockwork = "clockwork-bolt".equals(visualStyle);
paint.setColor(accessibilitySettings.highContrastEnabled()
? Color.WHITE : (clockwork ? Color.rgb(233, 181, 76) : Color.rgb(204, 211, 221)));
canvas.drawCircle(box.centerX(), box.centerY(), box.width() * 0.46f, paint);
stroke.setColor(accessibilitySettings.highContrastEnabled()
? Color.BLACK : Color.rgb(72, 56, 42));
stroke.setStrokeWidth(5f);
canvas.drawLine(box.left + 8f, box.centerY(), box.right - 8f, box.centerY(), stroke);
canvas.drawLine(box.centerX(), box.top + 8f, box.centerX(), box.bottom - 8f, stroke);
}
private void drawExplosion(Canvas canvas, RectF box, boolean active, String visualStyle) {
boolean steam = "steam-blast".equals(visualStyle);
float pulse = accessibilitySettings.reducedMotionEnabled()
? 0f : (float) Math.sin(levelElapsed * 5f) * 8f;
paint.setColor(Color.argb(active ? 95 : 28, steam ? 195 : 255, steam ? 220 : 124,
steam ? 231 : 52));
canvas.drawCircle(box.centerX(), box.centerY(),
Math.max(12f, box.width() * (active ? 0.47f : 0.2f) + pulse), paint);
stroke.setColor(accessibilitySettings.highContrastEnabled()
? Color.WHITE : Color.rgb(255, 218, 118));
stroke.setStrokeWidth(7f);
canvas.drawLine(box.left + 12f, box.top + 12f,
box.right - 12f, box.bottom - 12f, stroke);
canvas.drawLine(box.right - 12f, box.top + 12f,
box.left + 12f, box.bottom - 12f, stroke);
drawWorldLabel(canvas, active ? "BLAST" : "REST", box.centerX(), box.top - 20f);
}
private void drawSaw(Canvas canvas, RectF box) { private void drawSaw(Canvas canvas, RectF box) {
float angle = levelElapsed * 4.5f; float angle = levelElapsed * 4.5f;
@@ -1241,9 +1384,10 @@ public final class GameView extends View implements Runnable {
float cardW = 310f; float cardW = 310f;
float cardH = 250f; float cardH = 250f;
float gapX = 65f; float gapX = 65f;
for (int i = 0; i < LevelCatalog.all().size(); i++) { for (int i = levelPage.firstIndex(); i < levelPage.endExclusive(); i++) {
int col = i % 5; int localIndex = i - levelPage.firstIndex();
int row = i / 5; int col = localIndex % 5;
int row = localIndex / 5;
float left = startX + col * (cardW + gapX); float left = startX + col * (cardW + gapX);
float top = 260f + row * 310f; float top = 260f + row * 310f;
RectF card = new RectF(left, top, left + cardW, top + cardH); RectF card = new RectF(left, top, left + cardW, top + cardH);
@@ -1278,7 +1422,16 @@ public final class GameView extends View implements Runnable {
canvas.restore(); canvas.restore();
} }
drawHudButton(canvas, new RectF(1560, 70, 1850, 160), strings.settingsButton(), false); drawHudButton(canvas, new RectF(1560, 70, 1850, 160), strings.settingsButton(), false);
drawCenteredText(canvas, strings.levelSelectFooter(), VIEW_WIDTH * 0.5f, 980, 24, if (levelPage.hasPrevious()) {
drawHudButton(canvas, new RectF(50, 920, 330, 1010), strings.previousPage(), false);
}
if (levelPage.hasNext()) {
drawHudButton(canvas, new RectF(1590, 920, 1870, 1010), strings.nextPage(), false);
}
drawCenteredText(canvas, strings.pageIndicator(levelPage.pageIndex() + 1,
levelPage.pageCount()), VIEW_WIDTH * 0.5f, 965, 25,
Color.rgb(248, 212, 126));
drawCenteredText(canvas, strings.levelSelectFooter(), VIEW_WIDTH * 0.5f, 1042, 22,
Color.argb(190, 230, 230, 236)); Color.argb(190, 230, 230, 236));
} }
@@ -1651,13 +1804,26 @@ public final class GameView extends View implements Runnable {
refreshAccessibilityDescription(true); refreshAccessibilityDescription(true);
return; return;
} }
if (levelPage.hasPrevious() && contains(50, 920, 330, 1010, x, y)) {
levelPage.previous();
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
refreshAccessibilityDescription(true);
return;
}
if (levelPage.hasNext() && contains(1590, 920, 1870, 1010, x, y)) {
levelPage.next();
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
refreshAccessibilityDescription(true);
return;
}
float startX = 110f; float startX = 110f;
float cardW = 310f; float cardW = 310f;
float cardH = 250f; float cardH = 250f;
float gapX = 65f; float gapX = 65f;
for (int i = 0; i < LevelCatalog.all().size(); i++) { for (int i = levelPage.firstIndex(); i < levelPage.endExclusive(); i++) {
int col = i % 5; int localIndex = i - levelPage.firstIndex();
int row = i / 5; int col = localIndex % 5;
int row = localIndex / 5;
float left = startX + col * (cardW + gapX); float left = startX + col * (cardW + gapX);
float top = 260f + row * 310f; float top = 260f + row * 310f;
if (contains(left, top, left + cardW, top + cardH, x, y) && i < progress.unlockedCount()) { if (contains(left, top, left + cardW, top + cardH, x, y) && i < progress.unlockedCount()) {
@@ -1695,6 +1861,7 @@ public final class GameView extends View implements Runnable {
private void levelsFromResult() { private void levelsFromResult() {
if (screen != Screen.COMPLETE) return; if (screen != Screen.COMPLETE) return;
levelPage.selectIndex(levelIndex);
screen = Screen.LEVELS; screen = Screen.LEVELS;
pointers.clear(); pointers.clear();
deriveControls(); deriveControls();
@@ -1906,6 +2073,24 @@ public final class GameView extends View implements Runnable {
levelsFromResult(); levelsFromResult();
} }
public int levelPageIndexForAutomation() {
return levelPage.pageIndex();
}
public int levelPageCountForAutomation() {
return levelPage.pageCount();
}
public void nextLevelPageForAutomation() {
levelPage.next();
refreshAccessibilityDescription(true);
}
public void previousLevelPageForAutomation() {
levelPage.previous();
refreshAccessibilityDescription(true);
}
public void replayFromResultForAutomation() { public void replayFromResultForAutomation() {
replayFromResult(); replayFromResult();
} }
@@ -1936,7 +2121,7 @@ public final class GameView extends View implements Runnable {
if (screen == Screen.LEVELS) { if (screen == Screen.LEVELS) {
visible.add(strings.projectTitle()); visible.add(strings.projectTitle());
visible.add(strings.tagline()); visible.add(strings.tagline());
for (int i = 0; i < LevelCatalog.all().size(); i++) { for (int i = levelPage.firstIndex(); i < levelPage.endExclusive(); i++) {
boolean enabled = i < progress.unlockedCount(); boolean enabled = i < progress.unlockedCount();
visible.add(enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked()); visible.add(enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked());
visible.add(strings.levelTitle(i + 1)); visible.add(strings.levelTitle(i + 1));
@@ -1948,6 +2133,10 @@ public final class GameView extends View implements Runnable {
} }
} }
visible.add(strings.settingsButton()); visible.add(strings.settingsButton());
if (levelPage.hasPrevious()) visible.add(strings.previousPage());
if (levelPage.hasNext()) visible.add(strings.nextPage());
visible.add(strings.pageIndicator(levelPage.pageIndex() + 1,
levelPage.pageCount()));
visible.add(strings.levelSelectFooter()); visible.add(strings.levelSelectFooter());
} else if (screen == Screen.SETTINGS) { } else if (screen == Screen.SETTINGS) {
visible.add(strings.settingsTitle()); visible.add(strings.settingsTitle());
@@ -2344,6 +2533,7 @@ public final class GameView extends View implements Runnable {
startLevel(index); startLevel(index);
levelElapsed = snapshot.elapsedSeconds(); levelElapsed = snapshot.elapsedSeconds();
for (HazardRuntime runtime : hazardRuntimes) runtime.update(levelElapsed);
deaths = snapshot.deaths(); deaths = snapshot.deaths();
respawnTimer = snapshot.respawnSeconds(); respawnTimer = snapshot.respawnSeconds();
previousSwitchContact.clear(); previousSwitchContact.clear();
@@ -2436,6 +2626,16 @@ public final class GameView extends View implements Runnable {
&& player.supportIndex >= 0 && player.supportIndex >= 0
&& player.supportIndex < platformStates.size() && player.supportIndex < platformStates.size()
&& platformStates.get(player.supportIndex).definition.isMoving(); && platformStates.get(player.supportIndex).definition.isMoving();
boolean firstHazardActive = false;
float firstHazardX = Float.NaN;
float firstHazardY = Float.NaN;
if (!hazardRuntimes.isEmpty()) {
HazardRuntime firstHazard = hazardRuntimes.get(0);
LevelDefinition.Box firstHazardBox = firstHazard.currentBox();
firstHazardActive = firstHazard.active();
firstHazardX = firstHazardBox.x;
firstHazardY = firstHazardBox.y;
}
return new AutomationSnapshot( return new AutomationSnapshot(
screen == Screen.COMPLETE, screen == Screen.COMPLETE,
player.alive, player.alive,
@@ -2457,7 +2657,10 @@ public final class GameView extends View implements Runnable {
corpses.isEmpty() ? Float.NaN : corpses.get(0).x, corpses.isEmpty() ? Float.NaN : corpses.get(0).x,
player.onGround, player.onGround,
player.vx, player.vx,
player.vy player.vy,
firstHazardActive,
firstHazardX,
firstHazardY
); );
} }
@@ -0,0 +1,47 @@
package com.projectsacrifice.game;
/** Deterministic per-level runtime clock for one immutable hazard definition. */
public final class HazardRuntime {
private final LevelDefinition.Hazard definition;
private float elapsedSeconds;
public HazardRuntime(LevelDefinition.Hazard definition) {
if (definition == null) throw new IllegalArgumentException("definition is required");
this.definition = definition;
}
public LevelDefinition.Hazard definition() { return definition; }
public float elapsedSeconds() { return elapsedSeconds; }
public void update(float deltaSeconds) {
if (!Float.isFinite(deltaSeconds) || deltaSeconds < 0f) {
throw new IllegalArgumentException("deltaSeconds must be finite and non-negative");
}
elapsedSeconds += deltaSeconds;
if (definition.cycleSeconds > 0f && elapsedSeconds >= definition.cycleSeconds) {
elapsedSeconds %= definition.cycleSeconds;
}
}
public boolean active() {
boolean cyclicWindow = definition.type == LevelDefinition.HazardType.CRUSHER
|| definition.type == LevelDefinition.HazardType.EXPLOSION;
if (!cyclicWindow || definition.cycleSeconds <= 0f
|| definition.activeSeconds <= 0f) return true;
return elapsedSeconds < definition.activeSeconds;
}
public LevelDefinition.Box currentBox() {
if (definition.type != LevelDefinition.HazardType.PROJECTILE) {
return definition.box;
}
return new LevelDefinition.Box(
definition.box.x + definition.speedX * elapsedSeconds,
definition.box.y + definition.speedY * elapsedSeconds,
definition.box.width, definition.box.height);
}
public void reset() {
elapsedSeconds = 0f;
}
}
@@ -0,0 +1,61 @@
package com.projectsacrifice.game;
public final class LevelPage {
private final int totalItems;
private final int pageSize;
private int pageIndex;
public LevelPage(int totalItems, int pageSize) {
if (totalItems < 1) {
throw new IllegalArgumentException("totalItems must be positive");
}
if (pageSize < 1) {
throw new IllegalArgumentException("pageSize must be positive");
}
this.totalItems = totalItems;
this.pageSize = pageSize;
}
public int pageIndex() {
return pageIndex;
}
public int pageCount() {
return (totalItems + pageSize - 1) / pageSize;
}
public int firstIndex() {
return pageIndex * pageSize;
}
public int endExclusive() {
return Math.min(totalItems, firstIndex() + pageSize);
}
public boolean hasPrevious() {
return pageIndex > 0;
}
public boolean hasNext() {
return pageIndex + 1 < pageCount();
}
public void previous() {
if (hasPrevious()) {
pageIndex--;
}
}
public void next() {
if (hasNext()) {
pageIndex++;
}
}
public void selectIndex(int index) {
if (index < 0 || index >= totalItems) {
throw new IllegalArgumentException("index outside level page");
}
pageIndex = index / pageSize;
}
}
@@ -257,6 +257,8 @@ public final class LevelPlaytestBot {
+ ", completed=" + snapshot.completed() + ", completed=" + snapshot.completed()
+ ", deaths=" + snapshot.deaths() + ", deaths=" + snapshot.deaths()
+ ", corpses=" + snapshot.corpseCount() + ", corpses=" + snapshot.corpseCount()
+ ", firstCorpseX=" + snapshot.firstCorpseX()
+ ", firstHazardX=" + snapshot.firstHazardX()
+ ", allDoorsOpen=" + snapshot.allDoorsOpen(); + ", allDoorsOpen=" + snapshot.allDoorsOpen();
} }
@@ -106,6 +106,18 @@ final class ProceduralAudioEngine {
case DOOR: return variant == 0 ? ToneGenerator.TONE_CDMA_CONFIRM case DOOR: return variant == 0 ? ToneGenerator.TONE_CDMA_CONFIRM
: ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD; : ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD;
case DEATH_IMPACT: return ToneGenerator.TONE_DTMF_0 + Math.min(variant, 5); case DEATH_IMPACT: return ToneGenerator.TONE_DTMF_0 + Math.min(variant, 5);
case CRUSHER_IMPACT: return variant == 0 ? ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD
: variant == 1 ? ToneGenerator.TONE_CDMA_ABBR_INTERCEPT
: ToneGenerator.TONE_CDMA_SOFT_ERROR_LITE;
case FIRE_BURST: return variant == 0 ? ToneGenerator.TONE_CDMA_ALERT_AUTOREDIAL_LITE
: variant == 1 ? ToneGenerator.TONE_CDMA_ALERT_NETWORK_LITE
: ToneGenerator.TONE_CDMA_ALERT_INCALL_LITE;
case PROJECTILE_HIT: return variant == 0 ? ToneGenerator.TONE_SUP_ERROR
: variant == 1 ? ToneGenerator.TONE_SUP_CONGESTION
: ToneGenerator.TONE_SUP_RADIO_ACK;
case EXPLOSION_BLAST: return variant == 0 ? ToneGenerator.TONE_CDMA_HIGH_PBX_L
: variant == 1 ? ToneGenerator.TONE_CDMA_MED_PBX_L
: ToneGenerator.TONE_CDMA_LOW_PBX_L;
case DEATH_VOICE: return ToneGenerator.TONE_DTMF_C + Math.min(variant, 1); case DEATH_VOICE: return ToneGenerator.TONE_DTMF_C + Math.min(variant, 1);
case RESPAWN_VOICE: return variant == 0 ? ToneGenerator.TONE_CDMA_PIP case RESPAWN_VOICE: return variant == 0 ? ToneGenerator.TONE_CDMA_PIP
: variant == 1 ? ToneGenerator.TONE_CDMA_CALL_SIGNAL_ISDN_NORMAL : variant == 1 ? ToneGenerator.TONE_CDMA_CALL_SIGNAL_ISDN_NORMAL
@@ -126,6 +138,10 @@ final class ProceduralAudioEngine {
case SWITCH: return 85 + variant * 20; case SWITCH: return 85 + variant * 20;
case DOOR: return 180; case DOOR: return 180;
case DEATH_IMPACT: return 130 + variant * 18; case DEATH_IMPACT: return 130 + variant * 18;
case CRUSHER_IMPACT: return 170 + variant * 35;
case FIRE_BURST: return 145 + variant * 28;
case PROJECTILE_HIT: return 105 + variant * 22;
case EXPLOSION_BLAST: return 230 + variant * 40;
case DEATH_VOICE: return 260 + variant * 45; case DEATH_VOICE: return 260 + variant * 45;
case RESPAWN_VOICE: return 175 + variant * 35; case RESPAWN_VOICE: return 175 + variant * 35;
case GOAL_VOICE: return 320 + variant * 55; case GOAL_VOICE: return 320 + variant * 55;
@@ -64,7 +64,9 @@ public final class VersionedSessionCodec {
if (version < 1) return result(Status.CORRUPT, null); if (version < 1) return result(Status.CORRUPT, null);
int levelNumber = input.readInt(); int levelNumber = input.readInt();
if (levelNumber < 1 || levelNumber > 10) return result(Status.CORRUPT, null); if (levelNumber < 1 || levelNumber > LevelCatalog.all().size()) {
return result(Status.CORRUPT, null);
}
GameSessionSnapshot.Builder builder = GameSessionSnapshot.builder(levelNumber) GameSessionSnapshot.Builder builder = GameSessionSnapshot.builder(levelNumber)
.saveVersion(CURRENT_VERSION) .saveVersion(CURRENT_VERSION)
.elapsedSeconds(finiteNonNegative(input.readFloat())) .elapsedSeconds(finiteNonNegative(input.readFloat()))
@@ -45,6 +45,15 @@ public final class DeterministicInputTapeTest {
ReplayStateSignature.of(puzzle, false, false)); ReplayStateSignature.of(puzzle, false, false));
} }
@Test
public void productionTeachingLevelsCanOwnVersionedInputTapes() {
DeterministicInputTape tape = DeterministicInputTape.builder(14)
.record(false, true, true, 250L)
.build();
assertEquals(14, tape.levelNumber());
}
private static final class FakeTarget implements DeterministicInputTape.Target { private static final class FakeTarget implements DeterministicInputTape.Target {
private int level; private int level;
private long ticks; private long ticks;
@@ -27,13 +27,16 @@ public final class GameStringsTest {
assertEquals("Русский", russian.languageName()); assertEquals("Русский", russian.languageName());
assertEquals("Рыцарь-доброволец", russian.levelTitle(1)); assertEquals("Рыцарь-доброволец", russian.levelTitle(1));
assertEquals("Последнее шествие", russian.levelTitle(10)); assertEquals("Последнее шествие", russian.levelTitle(10));
assertEquals("Паровой заряд", russian.levelTitle(14));
assertEquals("Page 2 of 2", english.pageIndicator(2, 2));
assertEquals("Страница 2 из 2", russian.pageIndicator(2, 2));
assertEquals("Добровольцы 2 Время 00:07", russian.hudStats(2, 7_000L)); assertEquals("Добровольцы 2 Время 00:07", russian.hudStats(2, 7_000L));
assertEquals("✓ 2 добровольца · 00:07", russian.levelBestStats(2, 7_000L)); assertEquals("✓ 2 добровольца · 00:07", russian.levelBestStats(2, 7_000L));
assertEquals("★ 2 / 3 добровольца", russian.completionDeaths(true, 2, 3)); assertEquals("★ 2 / 3 добровольца", russian.completionDeaths(true, 2, 3));
assertEquals("Смерть — это инструмент", russian.tutorialWelcomeTitle()); assertEquals("Смерть — это инструмент", russian.tutorialWelcomeTitle());
assertFalse(russian.tutorialSacrificeBody().trim().isEmpty()); assertFalse(russian.tutorialSacrificeBody().trim().isEmpty());
for (int level = 1; level <= 10; level++) { for (int level = 1; level <= LevelCatalog.all().size(); level++) {
assertFalse(russian.levelTitle(level).trim().isEmpty()); assertFalse(russian.levelTitle(level).trim().isEmpty());
assertFalse(russian.levelHint(level).trim().isEmpty()); assertFalse(russian.levelHint(level).trim().isEmpty());
assertNotEquals(english.levelTitle(level), russian.levelTitle(level)); assertNotEquals(english.levelTitle(level), russian.levelTitle(level));
@@ -0,0 +1,59 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public final class HazardRuntimeTest {
@Test
public void crusherActiveWindowRepeatsOnItsDataDrivenCycle() {
LevelDefinition.Hazard definition = new LevelDefinition.Hazard(
LevelDefinition.HazardType.CRUSHER,
new LevelDefinition.Box(620f, 690f, 150f, 190f),
null, 0f, 180f, 4f, 1.25f, 0f, 0f,
"brass-press", "crusher-impact");
HazardRuntime runtime = new HazardRuntime(definition);
assertTrue(runtime.active());
runtime.update(1.2f);
assertTrue(runtime.active());
runtime.update(0.1f);
assertFalse(runtime.active());
runtime.update(2.7f);
assertTrue(runtime.active());
runtime.update(4f);
assertTrue(runtime.active());
}
@Test
public void projectileMovesFromItsSpawnBoxAndWrapsAtTheConfiguredCycle() {
LevelDefinition.Hazard definition = new LevelDefinition.Hazard(
LevelDefinition.HazardType.PROJECTILE,
new LevelDefinition.Box(620f, 790f, 48f, 48f),
null, 420f, -120f, 3f, 0f, 360f, 0f,
"clockwork-bolt", "projectile-hit");
HazardRuntime runtime = new HazardRuntime(definition);
runtime.update(1f);
assertTrue(runtime.currentBox().x == 980f);
runtime.update(2f);
assertTrue(runtime.currentBox().x == 620f);
}
@Test
public void explosionHasADataDrivenBlastAndRestWindow() {
LevelDefinition.Hazard definition = new LevelDefinition.Hazard(
LevelDefinition.HazardType.EXPLOSION,
new LevelDefinition.Box(620f, 740f, 140f, 140f),
null, 550f, -500f, 4f, 2f, 0f, 0f,
"steam-blast", "explosion-blast");
HazardRuntime runtime = new HazardRuntime(definition);
assertTrue(runtime.active());
runtime.update(2.1f);
assertFalse(runtime.active());
runtime.update(1.9f);
assertTrue(runtime.active());
}
}
@@ -12,9 +12,9 @@ import java.util.Set;
public final class LevelCatalogTest { public final class LevelCatalogTest {
@Test @Test
public void verticalSliceContainsTenSequentialLevels() { public void productionCatalogContainsFourHazardTeachingLevelsAfterTheVerticalSlice() {
List<LevelDefinition> levels = LevelCatalog.all(); List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(10, levels.size()); assertEquals(14, 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);
@@ -22,6 +22,10 @@ public final class LevelCatalogTest {
assertTrue(titles.add(level.title)); assertTrue(titles.add(level.title));
assertFalse(level.hint.trim().isEmpty()); assertFalse(level.hint.trim().isEmpty());
} }
assertTrue(levels.get(10).containsMechanic(LevelDefinition.HazardType.CRUSHER));
assertTrue(levels.get(11).containsMechanic(LevelDefinition.HazardType.FIRE));
assertTrue(levels.get(12).containsMechanic(LevelDefinition.HazardType.PROJECTILE));
assertTrue(levels.get(13).containsMechanic(LevelDefinition.HazardType.EXPLOSION));
} }
@Test @Test
@@ -126,6 +126,94 @@ public final class LevelJsonLoaderTest {
} }
} }
@Test
public void crusherLoadsItsCycleImpulseAudioAndVisualSettingsFromData() {
String json = validLevelJson(1).replace(
"\"hazards\":[],\"switches\":[],\"doors\":[]",
"\"hazards\":[{\"type\":\"CRUSHER\","
+ "\"box\":{\"x\":620,\"y\":690,\"width\":150,\"height\":190},"
+ "\"impulseX\":0,\"impulseY\":180,"
+ "\"cycleSeconds\":4,\"activeSeconds\":1.25,"
+ "\"speedX\":0,\"speedY\":0,"
+ "\"visualStyle\":\"brass-press\",\"audioCue\":\"crusher-impact\"}],"
+ "\"switches\":[],\"doors\":[]");
LevelDefinition.Hazard crusher = LevelJsonLoader.load(
new StringReader(json), "crusher.json").hazards.get(0);
assertEquals(LevelDefinition.HazardType.CRUSHER, crusher.type);
assertEquals(4f, crusher.cycleSeconds, 0f);
assertEquals(1.25f, crusher.activeSeconds, 0f);
assertEquals(180f, crusher.impulseY, 0f);
assertEquals("brass-press", crusher.visualStyle);
assertEquals("crusher-impact", crusher.audioCue);
}
@Test
public void fireLoadsItsCorpseImpulseAudioAndVisualSettingsFromData() {
String json = validLevelJson(1).replace(
"\"hazards\":[],\"switches\":[],\"doors\":[]",
"\"hazards\":[{\"type\":\"FIRE\","
+ "\"box\":{\"x\":700,\"y\":790,\"width\":120,\"height\":90},"
+ "\"impulseX\":160,\"impulseY\":-220,"
+ "\"visualStyle\":\"blue-flame\",\"audioCue\":\"fire-burst\"}],"
+ "\"switches\":[],\"doors\":[]");
LevelDefinition.Hazard fire = LevelJsonLoader.load(
new StringReader(json), "fire.json").hazards.get(0);
assertEquals(LevelDefinition.HazardType.FIRE, fire.type);
assertEquals(160f, fire.impulseX, 0f);
assertEquals(-220f, fire.impulseY, 0f);
assertEquals("blue-flame", fire.visualStyle);
assertEquals("fire-burst", fire.audioCue);
}
@Test
public void projectileLoadsItsVelocityCycleAudioAndVisualSettingsFromData() {
String json = validLevelJson(1).replace(
"\"hazards\":[],\"switches\":[],\"doors\":[]",
"\"hazards\":[{\"type\":\"PROJECTILE\","
+ "\"box\":{\"x\":620,\"y\":790,\"width\":48,\"height\":48},"
+ "\"impulseX\":420,\"impulseY\":-120,"
+ "\"cycleSeconds\":3,\"activeSeconds\":0,"
+ "\"speedX\":360,\"speedY\":0,"
+ "\"visualStyle\":\"clockwork-bolt\",\"audioCue\":\"projectile-hit\"}],"
+ "\"switches\":[],\"doors\":[]");
LevelDefinition.Hazard projectile = LevelJsonLoader.load(
new StringReader(json), "projectile.json").hazards.get(0);
assertEquals(LevelDefinition.HazardType.PROJECTILE, projectile.type);
assertEquals(3f, projectile.cycleSeconds, 0f);
assertEquals(360f, projectile.speedX, 0f);
assertEquals("clockwork-bolt", projectile.visualStyle);
assertEquals("projectile-hit", projectile.audioCue);
}
@Test
public void explosionLoadsItsRequiredImpulseAudioAndVisualSettingsFromData() {
String json = validLevelJson(1).replace(
"\"hazards\":[],\"switches\":[],\"doors\":[]",
"\"hazards\":[{\"type\":\"EXPLOSION\","
+ "\"box\":{\"x\":720,\"y\":740,\"width\":140,\"height\":140},"
+ "\"impulseX\":520,\"impulseY\":-380,"
+ "\"cycleSeconds\":4,\"activeSeconds\":2,"
+ "\"visualStyle\":\"steam-blast\",\"audioCue\":\"explosion-blast\"}],"
+ "\"switches\":[],\"doors\":[]");
LevelDefinition.Hazard explosion = LevelJsonLoader.load(
new StringReader(json), "explosion.json").hazards.get(0);
assertEquals(LevelDefinition.HazardType.EXPLOSION, explosion.type);
assertEquals(520f, explosion.impulseX, 0f);
assertEquals(-380f, explosion.impulseY, 0f);
assertEquals(4f, explosion.cycleSeconds, 0f);
assertEquals(2f, explosion.activeSeconds, 0f);
assertEquals("steam-blast", explosion.visualStyle);
assertEquals("explosion-blast", explosion.audioCue);
}
static String validLevelJson(int formatVersion) { static String validLevelJson(int formatVersion) {
return "{" return "{"
+ "\"formatVersion\":" + formatVersion + "," + "\"formatVersion\":" + formatVersion + ","
@@ -0,0 +1,33 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public final class LevelPageTest {
@Test
public void fourteenLevelsUseTwoBoundedTenCardPages() {
LevelPage page = new LevelPage(14, 10);
assertEquals(2, page.pageCount());
assertEquals(0, page.firstIndex());
assertEquals(10, page.endExclusive());
assertFalse(page.hasPrevious());
assertTrue(page.hasNext());
page.next();
assertEquals(10, page.firstIndex());
assertEquals(14, page.endExclusive());
assertTrue(page.hasPrevious());
assertFalse(page.hasNext());
page.next();
assertEquals(1, page.pageIndex());
page.selectIndex(3);
assertEquals(0, page.pageIndex());
page.selectIndex(13);
assertEquals(1, page.pageIndex());
}
}
@@ -58,6 +58,22 @@ public final class VersionedSessionCodecTest {
assertNull(future.snapshot()); assertNull(future.snapshot());
} }
@Test
public void productionTeachingLevelSnapshotRoundTrips() {
GameSessionSnapshot snapshot = GameSessionSnapshot.builder(14)
.elapsedSeconds(3f)
.player(new GameSessionSnapshot.PlayerData(
130f, 760f, 0f, 0f, true, true,
0f, 0f, -1, 1))
.build();
VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode(
VersionedSessionCodec.encode(snapshot));
assertEquals(VersionedSessionCodec.Status.RESTORED, decoded.status());
assertEquals(14, decoded.snapshot().levelNumber());
}
private static GameSessionSnapshot sampleSnapshot() { private static GameSessionSnapshot sampleSnapshot() {
return GameSessionSnapshot.builder(5) return GameSessionSnapshot.builder(5)
.elapsedSeconds(12.75f) .elapsedSeconds(12.75f)
+6 -4
View File
@@ -178,16 +178,18 @@ baseline-задачи также имели явного владельца по
- content не содержит blocker-softlocks и обязательных невосстановимых объектов; - content не содержит blocker-softlocks и обязательных невосстановимых объектов;
- принят `goon-game-milestone-production`. - принят `goon-game-milestone-production`.
Статус на 19 августа 2026: Stage 6 начат в изолированном production stream. Статус на 19 августа 2026: Stage 6 выполняется в изолированном production stream.
Завершены три foundation slice. `goon-game-character-states` добавил Завершены три foundation slice. `goon-game-character-states` добавил
явные Idle/Run/Turn/Jump/Fall/Land/Interact/Stunned/Death transitions. явные Idle/Run/Turn/Jump/Fall/Land/Interact/Stunned/Death transitions.
`goon-game-corpse-manager` перенёс регистрацию, transforms, FIFO budget, reset, `goon-game-corpse-manager` перенёс регистрацию, transforms, FIFO budget, reset,
save/restore и diagnostics тел в отдельного владельца без изменения физики и save/restore и diagnostics тел в отдельного владельца без изменения физики и
геометрии. `goon-game-puzzle-events` выделил pressure/toggle/timed/relay channels, геометрии. `goon-game-puzzle-events` выделил pressure/toggle/timed/relay channels,
immutable events и явные DoorController states; двери и платформы читают общую immutable events и явные DoorController states; двери и платформы читают общую
data-driven модель. Последний gate: 89 JVM-тестов и Android 16 matrix `38/38`. data-driven модель. Завершён первый mechanics slice: production-каталог hazards
Следующая итерация — production-каталог hazards (`crusher`, `fire`, projectile, (`crusher`, `fire`, `projectile`, `explosion`), обучающие уровни 11–14,
explosion), который затем даёт источники для новых corpse states. динамическая пагинация каталога и 12/12 bot-прохождений. Последний gate:
99 JVM-тестов и Android 16 matrix `43/43` (`2004.172 s`). Следующая зависимая
итерация — новые corpse states на источниках production hazards.
### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней ### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней
+76 -1
View File
@@ -285,9 +285,84 @@ Goal расположен на верхнем уступе за PIT и закр
**Softlock/неопределённость:** исправлены два независимых blocker-состояния: saw-тело больше не соскальзывает с плиты после доставки, а перекрытие floor/lift увеличено до 140 px при сохранении 200 px `PIT` и обязательного вертикального подъёма. Период lift увеличен до `10.0 s`, чтобы нижняя фаза была читаемой при задержанном вводе. Бот теперь явно приземляется на frozen corpse и прыгает с него; доставка saw-тела, три сохранённых тела, посадка, подъём и lift→ledge прыжок подтверждены тремя запусками и стресс-сценарием. Возможная альтернативная двухсмертная трасса не считается blocker, пока intended three-body route остаётся стабильным. **Softlock/неопределённость:** исправлены два независимых blocker-состояния: saw-тело больше не соскальзывает с плиты после доставки, а перекрытие floor/lift увеличено до 140 px при сохранении 200 px `PIT` и обязательного вертикального подъёма. Период lift увеличен до `10.0 s`, чтобы нижняя фаза была читаемой при задержанном вводе. Бот теперь явно приземляется на frozen corpse и прыгает с него; доставка saw-тела, три сохранённых тела, посадка, подъём и lift→ledge прыжок подтверждены тремя запусками и стресс-сценарием. Возможная альтернативная двухсмертная трасса не считается blocker, пока intended three-body route остаётся стабильным.
## Уровень 11 — `The Brass Breather`
**Статус:** доказан production-input ботом, 3/3 изолированно и 3/3 в общей hazard-матрице.
**Ожидаемые и доказанные смерти:** `1`.
```text
CRUSHER перекрывает единственный проход
→ первый доброволец входит в активный пресс и оставляет тело
→ следующий доброволец подходит к безопасной границе
→ наблюдает закрытие и следующий переход пресса в REST
→ прыгает через сохранённое тело и проходит пресс в полном окне отдыха
→ достигает goal с одной смертью
```
**Контракт:** `cycleSeconds=4`, `activeSeconds=2.5`; collision активен только в
CRUSH-фазе. Bot snapshot наблюдает `firstHazardActive`, не использует sleeps,
restart или прямую мутацию состояния.
## Уровень 12 — `Blue Funeral`
**Статус:** доказан production-input ботом, 3/3 изолированно и 3/3 в общей hazard-матрице.
**Ожидаемые и доказанные смерти:** `1`.
```text
FIRE перекрывает floor
→ первый доброволец демонстрирует смертельность синего огня
→ после respawn игрок подходит к рассчитанной точке x≈535
→ отдельным takeoff подтверждает airborne state
→ пересекает весь flame box до приземления
→ достигает goal, тело остаётся в уровне
```
**Контракт:** постоянный `FIRE=(700,790,120,90)` с отдельными visual/audio IDs.
Маршрут запрещает вторую смерть на approach, takeoff и clear checkpoints.
## Уровень 13 — `Crossfire Clock`
**Статус:** доказан production-input ботом, 3/3 изолированно и 3/3 в общей hazard-матрице.
**Ожидаемые и доказанные смерти:** `1`.
```text
PROJECTILE каждые 3 s проходит справа налево
→ первый доброволец погибает и показывает линию снаряда
→ после respawn игрок останавливается у x≈600
→ ждёт наблюдаемое окно projectile x=850..930 и прыгает через первый проход
→ останавливается за линией и ждёт следующий цикл x=1350..1450
→ вторым прыжком проходит возвращённый снаряд
→ добегает за spawn снаряда и достигает goal без новых смертей
```
**Контракт:** runtime вычисляет текущий box из `speedX=-400` и сбрасывает его
на исходную позицию по `cycleSeconds=3`. Оба прыжка привязаны к snapshot
координате, а не к wall-clock.
## Уровень 14 — `Steam Charge`
**Статус:** доказан production-input ботом, 3/3 изолированно и 3/3 в общей hazard-матрице.
**Ожидаемые и доказанные смерти:** `1`; дверь обязана открыться.
```text
Door требует signal "blast-gate" от удалённой PRESSURE
→ первый доброволец входит в активный EXPLOSION
→ impulse (550,-500) доставляет тело на PRESSURE x=980..1160
→ тело закрепляется на плите и открывает Door
→ следующий доброволец наблюдает новый BLAST и следующую REST-фазу
→ проходит inactive explosion
→ перепрыгивает сохранённое тело на плите
→ проходит открытую Door и достигает goal
```
**Контракт:** `EXPLOSION` использует data-driven цикл `4/2 s`; inactive-фаза
отличается визуально (`REST`) и не убивает. Device RED с `impulseX=1400`
доказал перелёт тела за плиту; минимальное authored значение `550` стабильно
помещает тело в `firstCorpseX=980`, после чего `allDoorsOpen=true`.
## Проверки, следующие из графов ## Проверки, следующие из графов
1. Сохранять полный integrated device-regression: intended route уровней 1–10 по три раза и отдельный recovery route уровня 8. 1. Сохранять полный integrated device-regression: intended route принятых уровней по три раза и отдельный recovery route уровня 8.
2. Отдельно проверить предполагаемый sequence break уровня 2 и необязательность `FREEZER` на уровне 10. 2. Отдельно проверить предполагаемый sequence break уровня 2 и необязательность `FREEZER` на уровне 10.
3. Добавить параметризованные проверки restart из ключевых промежуточных состояний. 3. Добавить параметризованные проверки restart из ключевых промежуточных состояний.
4. Повторить bot matrix на дополнительных frame rates и поддерживаемых версиях Android. 4. Повторить bot matrix на дополнительных frame rates и поддерживаемых версиях Android.
+68
View File
@@ -0,0 +1,68 @@
# Stage 6 production hazards
Date: 19 August 2026
Beads task: `goon-game-hazards-full`
## Delivered contract
- JSON hazard definitions now support crusher, fire, projectile and explosion
types with data-bound cycle, active window, velocity, visual style and audio cue.
- `HazardRuntime` owns deterministic cycle state and moving projectile geometry.
- `GameView` collision, drawing, procedural audio and restore consume the same
runtime definition; no hazard timing is duplicated in the bot.
- Four focused teaching levels extend the catalog from 10 to 14.
- The level-select UI pages an arbitrary catalog in groups of ten and exposes all
four new levels through real touch controls and RU/EN copy.
- Tape/session validation derives the upper level bound from `LevelCatalog`
instead of a hard-coded ten-level limit.
## Teaching routes
- Level 11: sacrifice to a rhythmic crusher, then cross during a fresh REST window.
- Level 12: sacrifice to fire, then take off from the safe staging point and clear it.
- Level 13: read and evade two consecutive projectile cycles after one sacrifice.
- Level 14: explosion-deliver a body to a pressure plate, cross the next REST
window, jump the retained body and pass the opened door.
Every route uses production input and read-only snapshots. It has no restart,
retry, teleport or direct completion mutation and asserts exactly one death and
one retained body. Level 14 additionally requires the door-open event.
## TDD evidence
RED cycles covered missing schema symbols, missing runtime, each new hazard type,
the old hard-coded ten-level tape/session bound, missing level pagination and
missing intended-route scenarios. Device REDs then exposed:
- a crusher crossing begun midway through its rest interval;
- a fire jump staged too early and descending into the far edge;
- a second projectile cycle not handled by the initial route;
- an explosion impulse that overshot its required plate;
- an always-active explosion box that could not be cleared with release physics;
- the retained level-14 body correctly blocking the living player at the plate.
Each was resolved at the narrowest responsible layer: state-driven choreography,
authored explosion impulse/cycle, and an explicit jump over the retained body.
## Focused acceptance
- Validator: `14/14`, format `1`, physics `1`.
- Level 11: `3/3`, `46.414 s`.
- Level 12: `3/3`, `41.143 s`.
- Level 13: `3/3`, `44.630 s`.
- Level 14: `3/3`, `52.089 s`.
- Combined one-install matrix: four routes, twelve playthroughs plus touch
pagination, `5/5` test methods, `221.894 s`.
## Final gates
- JVM: `99`, failures `0`, errors `0`, skipped `0`.
- `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN from clean.
- final unfiltered Android 16 matrix: `43/43`, failures `0`, `2004.172 s`, on
the exact clean-built final app/test APK.
- debug APK ZIP integrity: `unzip -t` GREEN and contains all 14 JSON levels.
- debug APK SHA-256:
`f220ada1da1e32bbca15615487f49a1ac3b61d924af292b648b6693a14d5f212`.
The dedicated AVD was stopped after the gate.
+1 -1
View File
@@ -65,7 +65,7 @@ Snapshot на 19 августа 2026 года:
| 3 | 5 | 0 | | 3 | 5 | 0 |
| 4 | 4 | 0 | | 4 | 4 | 0 |
| 5 | 15 | 0 | | 5 | 15 | 0 |
| 6 | 18 | 18 | | 6 | 18 | 12 |
| 7 | 9 | 9 | | 7 | 9 | 9 |
| 8 | 1 | 1 | | 8 | 1 | 1 |
| 9 | 1 | 1 | | 9 | 1 | 1 |
@@ -6,7 +6,9 @@ import java.util.List;
/** Immutable, Android-independent level data. */ /** Immutable, Android-independent level data. */
public final class LevelDefinition { public final class LevelDefinition {
public enum HazardType { SPIKES, SAW, FREEZER, ELECTRIC, PIT } public enum HazardType {
SPIKES, SAW, FREEZER, ELECTRIC, PIT, CRUSHER, FIRE, PROJECTILE, EXPLOSION
}
public enum SwitchType { PRESSURE, TOGGLE, TIMED } public enum SwitchType { PRESSURE, TOGGLE, TIMED }
public static final class Box { public static final class Box {
@@ -54,17 +56,36 @@ public final class LevelDefinition {
public final String relayId; public final String relayId;
public final float impulseX; public final float impulseX;
public final float impulseY; public final float impulseY;
public final float cycleSeconds;
public final float activeSeconds;
public final float speedX;
public final float speedY;
public final String visualStyle;
public final String audioCue;
public Hazard(HazardType type, Box box) { public Hazard(HazardType type, Box box) {
this(type, box, null, 0f, 0f); this(type, box, null, 0f, 0f);
} }
public Hazard(HazardType type, Box box, String relayId, float impulseX, float impulseY) { public Hazard(HazardType type, Box box, String relayId, float impulseX, float impulseY) {
this(type, box, relayId, impulseX, impulseY,
0f, 0f, 0f, 0f, null, null);
}
public Hazard(HazardType type, Box box, String relayId, float impulseX, float impulseY,
float cycleSeconds, float activeSeconds, float speedX, float speedY,
String visualStyle, String audioCue) {
this.type = type; this.type = type;
this.box = box; this.box = box;
this.relayId = relayId; this.relayId = relayId;
this.impulseX = impulseX; this.impulseX = impulseX;
this.impulseY = impulseY; this.impulseY = impulseY;
this.cycleSeconds = cycleSeconds;
this.activeSeconds = activeSeconds;
this.speedX = speedX;
this.speedY = speedY;
this.visualStyle = visualStyle;
this.audioCue = audioCue;
} }
} }
@@ -106,7 +106,13 @@ public final class LevelJsonLoader {
box(required(value, "box", path), path + ".box"), box(required(value, "box", path), path + ".box"),
optionalText(value, "relayId", path), optionalText(value, "relayId", path),
optionalDecimal(value, "impulseX", path, 0f), optionalDecimal(value, "impulseX", path, 0f),
optionalDecimal(value, "impulseY", path, 0f))); optionalDecimal(value, "impulseY", path, 0f),
optionalDecimal(value, "cycleSeconds", path, 0f),
optionalDecimal(value, "activeSeconds", path, 0f),
optionalDecimal(value, "speedX", path, 0f),
optionalDecimal(value, "speedY", path, 0f),
optionalText(value, "visualStyle", path),
optionalText(value, "audioCue", path)));
} }
return result; return result;
} }
@@ -40,7 +40,47 @@ public final class LevelValidator {
} }
} }
for (int i = 0; i < level.hazards.size(); i++) { for (int i = 0; i < level.hazards.size(); i++) {
box(level.hazards.get(i).box, level, source + ".hazards[" + i + "].box"); LevelDefinition.Hazard hazard = level.hazards.get(i);
String path = source + ".hazards[" + i + "]";
box(hazard.box, level, path + ".box");
if (hazard.type == LevelDefinition.HazardType.CRUSHER) {
positive(hazard.cycleSeconds, path + ".cycleSeconds");
positive(hazard.activeSeconds, path + ".activeSeconds");
if (hazard.activeSeconds > hazard.cycleSeconds) {
throw invalid(path + ".activeSeconds", "must not exceed cycleSeconds");
}
requiredText(hazard.visualStyle, path + ".visualStyle");
requiredText(hazard.audioCue, path + ".audioCue");
}
if (hazard.type == LevelDefinition.HazardType.FIRE) {
requiredText(hazard.visualStyle, path + ".visualStyle");
requiredText(hazard.audioCue, path + ".audioCue");
}
if (hazard.type == LevelDefinition.HazardType.PROJECTILE) {
positive(hazard.cycleSeconds, path + ".cycleSeconds");
if (hazard.speedX == 0f && hazard.speedY == 0f) {
throw invalid(path + ".speedX", "or speedY must be non-zero");
}
LevelDefinition.Box destination = new LevelDefinition.Box(
hazard.box.x + hazard.speedX * hazard.cycleSeconds,
hazard.box.y + hazard.speedY * hazard.cycleSeconds,
hazard.box.width, hazard.box.height);
box(destination, level, path + ".destinationBox");
requiredText(hazard.visualStyle, path + ".visualStyle");
requiredText(hazard.audioCue, path + ".audioCue");
}
if (hazard.type == LevelDefinition.HazardType.EXPLOSION) {
positive(hazard.cycleSeconds, path + ".cycleSeconds");
positive(hazard.activeSeconds, path + ".activeSeconds");
if (hazard.activeSeconds > hazard.cycleSeconds) {
throw invalid(path + ".activeSeconds", "must not exceed cycleSeconds");
}
if (hazard.impulseX == 0f && hazard.impulseY == 0f) {
throw invalid(path + ".impulseX", "or impulseY must be non-zero");
}
requiredText(hazard.visualStyle, path + ".visualStyle");
requiredText(hazard.audioCue, path + ".audioCue");
}
} }
for (int i = 0; i < level.switches.size(); i++) { for (int i = 0; i < level.switches.size(); i++) {
LevelDefinition.Switch item = level.switches.get(i); LevelDefinition.Switch item = level.switches.get(i);
@@ -112,6 +152,10 @@ public final class LevelValidator {
if (!Float.isFinite(value) || value <= 0f) throw invalid(path, "must be positive and finite"); if (!Float.isFinite(value) || value <= 0f) throw invalid(path, "must be positive and finite");
} }
private static void requiredText(String value, String path) {
if (value == null || value.trim().isEmpty()) throw invalid(path, "must not be blank");
}
private static IllegalArgumentException invalid(String path, String message) { private static IllegalArgumentException invalid(String path, String message) {
return new IllegalArgumentException(path + " " + message); return new IllegalArgumentException(path + " " + message);
} }
@@ -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"]} {"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"]}
@@ -0,0 +1,11 @@
{
"formatVersion":1,"physicsVersion":1,"number":11,
"copy":{"en":{"title":"The Brass Breather","hint":"The press has a rhythm. Leave one volunteer, then cross while its jaws rest."},"ru":{"title":"Латунный пресс","hint":"У пресса есть ритм. Оставьте одного добровольца и пройдите, пока створки отдыхают."}},
"world":{"width":2400,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2180,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":35},
"platforms":[{"box":{"x":0,"y":880,"width":2400,"height":200}}],
"hazards":[{"type":"CRUSHER","box":{"x":700,"y":690,"width":150,"height":190},
"impulseX":80,"impulseY":120,"cycleSeconds":4,"activeSeconds":2.5,
"speedX":0,"speedY":0,"visualStyle":"brass-press","audioCue":"crusher-impact"}],
"switches":[],"doors":[]
}
@@ -0,0 +1,11 @@
{
"formatVersion":1,"physicsVersion":1,"number":12,
"copy":{"en":{"title":"Blue Funeral","hint":"Flame is final for the living. Sacrifice once, then jump the blue fire."},"ru":{"title":"Синее пламя","hint":"Огонь смертелен для живых. Пожертвуйте одним и перепрыгните синее пламя."}},
"world":{"width":2400,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2180,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":35},
"platforms":[{"box":{"x":0,"y":880,"width":2400,"height":200}}],
"hazards":[{"type":"FIRE","box":{"x":700,"y":790,"width":120,"height":90},
"impulseX":160,"impulseY":-220,"cycleSeconds":0,"activeSeconds":0,
"speedX":0,"speedY":0,"visualStyle":"blue-flame","audioCue":"fire-burst"}],
"switches":[],"doors":[]
}
@@ -0,0 +1,11 @@
{
"formatVersion":1,"physicsVersion":1,"number":13,
"copy":{"en":{"title":"Crossfire Clock","hint":"The clockwork bolt always returns. Read its line, then jump through the gap."},"ru":{"title":"Часовой обстрел","hint":"Заводной снаряд всегда возвращается. Прочитайте траекторию и прыгните в окно."}},
"world":{"width":2400,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2180,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":40},
"platforms":[{"box":{"x":0,"y":880,"width":2400,"height":200}}],
"hazards":[{"type":"PROJECTILE","box":{"x":1700,"y":790,"width":48,"height":48},
"impulseX":420,"impulseY":-120,"cycleSeconds":3,"activeSeconds":0,
"speedX":-400,"speedY":0,"visualStyle":"clockwork-bolt","audioCue":"projectile-hit"}],
"switches":[],"doors":[]
}
@@ -0,0 +1,13 @@
{
"formatVersion":1,"physicsVersion":1,"number":14,
"copy":{"en":{"title":"Steam Charge","hint":"The blast throws what it takes. Deliver a body to the plate and use the open gate."},"ru":{"title":"Паровой заряд","hint":"Взрыв отбрасывает жертву. Доставьте тело на плиту и пройдите в открытые ворота."}},
"world":{"width":2500,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2260,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":45},
"platforms":[{"box":{"x":0,"y":880,"width":2500,"height":200}}],
"hazards":[{"type":"EXPLOSION","box":{"x":620,"y":740,"width":140,"height":140},
"impulseX":550,"impulseY":-500,"cycleSeconds":4,"activeSeconds":2,
"speedX":0,"speedY":0,"visualStyle":"steam-blast","audioCue":"explosion-blast"}],
"switches":[{"type":"PRESSURE","box":{"x":980,"y":860,"width":180,"height":20},
"signalId":"blast-gate","durationSeconds":0,"requiredMass":1}],
"doors":[{"box":{"x":1400,"y":560,"width":82,"height":320},"requiredSignals":["blast-gate"]}]
}