Добавить production-каталог опасностей
This commit is contained in:
+2
-2
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -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)
|
||||||
|
|||||||
@@ -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 дней
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"]}]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user