From dc2ebcbf8c26a7b4c0af93937f185e7ce5674352 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Wed, 19 Aug 2026 05:30:56 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20production-=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=20=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=D1=81=D1=82=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .beads/issues.jsonl | 4 +- README.md | 9 +- .../game/LevelPaginationTest.java | 64 +++++ .../game/ProductionHazardPlaythroughTest.java | 94 +++++++ .../game/ProductionHazardScenarios.java | 143 +++++++++++ .../com/projectsacrifice/game/AudioCue.java | 4 + .../game/DeterministicInputTape.java | 2 +- .../projectsacrifice/game/GameStrings.java | 14 +- .../com/projectsacrifice/game/GameView.java | 233 ++++++++++++++++-- .../projectsacrifice/game/HazardRuntime.java | 47 ++++ .../com/projectsacrifice/game/LevelPage.java | 61 +++++ .../game/LevelPlaytestBot.java | 2 + .../game/ProceduralAudioEngine.java | 16 ++ .../game/VersionedSessionCodec.java | 4 +- .../game/DeterministicInputTapeTest.java | 9 + .../game/GameStringsTest.java | 5 +- .../game/HazardRuntimeTest.java | 59 +++++ .../game/LevelCatalogTest.java | 8 +- .../game/LevelJsonLoaderTest.java | 88 +++++++ .../projectsacrifice/game/LevelPageTest.java | 33 +++ .../game/VersionedSessionCodecTest.java | 16 ++ docs/CURRENT_ROADMAP.md | 10 +- docs/LEVEL_SOLUTION_GRAPHS.md | 77 +++++- docs/STAGE6_PRODUCTION_HAZARDS.md | 68 +++++ docs/STAGE_REGISTRY.md | 2 +- .../game/LevelDefinition.java | 23 +- .../game/LevelJsonLoader.java | 8 +- .../projectsacrifice/game/LevelValidator.java | 46 +++- .../src/main/resources/levels/index.json | 2 +- .../src/main/resources/levels/level-11.json | 11 + .../src/main/resources/levels/level-12.json | 11 + .../src/main/resources/levels/level-13.json | 11 + .../src/main/resources/levels/level-14.json | 13 + 33 files changed, 1160 insertions(+), 37 deletions(-) create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/ProductionHazardPlaythroughTest.java create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/ProductionHazardScenarios.java create mode 100644 app/src/main/java/com/projectsacrifice/game/HazardRuntime.java create mode 100644 app/src/main/java/com/projectsacrifice/game/LevelPage.java create mode 100644 app/src/test/java/com/projectsacrifice/game/HazardRuntimeTest.java create mode 100644 app/src/test/java/com/projectsacrifice/game/LevelPageTest.java create mode 100644 docs/STAGE6_PRODUCTION_HAZARDS.md create mode 100644 level-format/src/main/resources/levels/level-11.json create mode 100644 level-format/src/main/resources/levels/level-12.json create mode 100644 level-format/src/main/resources/levels/level-13.json create mode 100644 level-format/src/main/resources/levels/level-14.json diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index afe0cb2..d66f13b 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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-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-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-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} @@ -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-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-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-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} diff --git a/README.md b/README.md index a4e608f..ce60870 100644 --- a/README.md +++ b/README.md @@ -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 deterministic character state machine, a bounded `CorpseManager`, and an -event-driven `PuzzleSystem` with explicit signal and door transitions. Scope and -final evidence are in `docs/STAGE6_CHARACTER_STATES.md`, +event-driven `PuzzleSystem` with explicit signal and door transitions. The first +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 -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: diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java b/app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java new file mode 100644 index 0000000..c5c29db --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/LevelPaginationTest.java @@ -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 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 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 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(); + }); + } +} diff --git a/app/src/androidTest/java/com/projectsacrifice/game/ProductionHazardPlaythroughTest.java b/app/src/androidTest/java/com/projectsacrifice/game/ProductionHazardPlaythroughTest.java new file mode 100644 index 0000000..7ff47c5 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/ProductionHazardPlaythroughTest.java @@ -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 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 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); + } + } + } +} diff --git a/app/src/androidTest/java/com/projectsacrifice/game/ProductionHazardScenarios.java b/app/src/androidTest/java/com/projectsacrifice/game/ProductionHazardScenarios.java new file mode 100644 index 0000000..ca14269 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/ProductionHazardScenarios.java @@ -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(); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/AudioCue.java b/app/src/main/java/com/projectsacrifice/game/AudioCue.java index c6defe7..abea11e 100644 --- a/app/src/main/java/com/projectsacrifice/game/AudioCue.java +++ b/app/src/main/java/com/projectsacrifice/game/AudioCue.java @@ -8,6 +8,10 @@ public enum AudioCue { SWITCH(Channel.EFFECTS, 3), DOOR(Channel.EFFECTS, 2), 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), RESPAWN_VOICE(Channel.VOICE, 3), GOAL_VOICE(Channel.VOICE, 3), diff --git a/app/src/main/java/com/projectsacrifice/game/DeterministicInputTape.java b/app/src/main/java/com/projectsacrifice/game/DeterministicInputTape.java index 5f217e5..263e22c 100644 --- a/app/src/main/java/com/projectsacrifice/game/DeterministicInputTape.java +++ b/app/src/main/java/com/projectsacrifice/game/DeterministicInputTape.java @@ -40,7 +40,7 @@ public final class DeterministicInputTape { private final ArrayList commands = new ArrayList<>(); private Builder(int levelNumber) { - if (levelNumber < 1 || levelNumber > 10) { + if (levelNumber < 1 || levelNumber > LevelCatalog.all().size()) { throw new IllegalArgumentException("Unknown level: " + levelNumber); } this.levelNumber = levelNumber; diff --git a/app/src/main/java/com/projectsacrifice/game/GameStrings.java b/app/src/main/java/com/projectsacrifice/game/GameStrings.java index 6855f41..11401c7 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameStrings.java +++ b/app/src/main/java/com/projectsacrifice/game/GameStrings.java @@ -22,8 +22,10 @@ public final class GameStrings { public String appContentDescription() { return russian() - ? "Project Sacrifice. Платформенная головоломка из десяти уровней." - : "Project Sacrifice. Ten-level puzzle platform game."; + ? "Project Sacrifice. Платформенная головоломка, уровней: " + + LevelCatalog.all().size() + "." + : "Project Sacrifice. Puzzle platform game with " + + LevelCatalog.all().size() + " levels."; } public String projectTitle() { return "PROJECT SACRIFICE"; } @@ -43,6 +45,14 @@ public final class GameStrings { 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 settingsInstruction() { return russian() ? "Нажмите на строку, чтобы изменить. Всё сохраняется автоматически." diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index e35a10d..78694d2 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -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 RESPAWN_SECONDS = PHYSICS.respawnSeconds(); 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 DeathType { SPIKE, SAW, FREEZE, ELECTRIC, PIT } + private enum DeathType { + SPIKE, SAW, FREEZE, ELECTRIC, PIT, CRUSH, FIRE, PROJECTILE, EXPLOSION + } public static final class AutomationSnapshot { private final boolean completed; @@ -69,6 +72,9 @@ public final class GameView extends View implements Runnable { private final boolean playerOnGround; private final float playerVelocityX; private final float playerVelocityY; + private final boolean firstHazardActive; + private final float firstHazardX; + private final float firstHazardY; AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen, boolean visiblyPaused, boolean inputActive, int levelNumber, @@ -137,6 +143,26 @@ public final class GameView extends View implements Runnable { boolean firstMovingPlatformMovingDown, float firstCorpseX, boolean playerOnGround, float playerVelocityX, 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.playerAlive = playerAlive; this.allDoorsOpen = allDoorsOpen; @@ -158,6 +184,9 @@ public final class GameView extends View implements Runnable { this.playerOnGround = playerOnGround; this.playerVelocityX = playerVelocityX; this.playerVelocityY = playerVelocityY; + this.firstHazardActive = firstHazardActive; + this.firstHazardX = firstHazardX; + this.firstHazardY = firstHazardY; } public boolean completed() { return completed; } @@ -181,6 +210,9 @@ public final class GameView extends View implements Runnable { public boolean playerOnGround() { return playerOnGround; } public float playerVelocityX() { return playerVelocityX; } 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 { @@ -229,6 +261,7 @@ public final class GameView extends View implements Runnable { private final CorpseManager corpseManager = new CorpseManager(MAX_CORPSES); private final List corpses = corpseManager.bodies(); private final ArrayList platformStates = new ArrayList<>(); + private final ArrayList hazardRuntimes = new ArrayList<>(); private final Map previousSwitchContact = new HashMap<>(); private final Map doorControllers = new IdentityHashMap<>(); @@ -240,6 +273,8 @@ public final class GameView extends View implements Runnable { private final CameraController cameraController = new CameraController(VIEW_WIDTH, VIEW_HEIGHT); 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 LevelDefinition level; @@ -394,12 +429,16 @@ public final class GameView extends View implements Runnable { deriveControls(); corpseManager.reset(); platformStates.clear(); + hazardRuntimes.clear(); previousSwitchContact.clear(); configurePuzzleSystem(); completionResult = null; configureAccessibilitySignals(); invalidateHudTextCache(); for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item)); + for (LevelDefinition.Hazard item : level.hazards) { + hazardRuntimes.add(new HazardRuntime(item)); + } spawnPlayer(); lastFrameNanos = System.nanoTime(); refreshAccessibilityDescription(true); @@ -428,6 +467,7 @@ public final class GameView extends View implements Runnable { private void update(float dt) { levelElapsed += dt; audioEngine.updateMusic(levelElapsed, level.number); + for (HazardRuntime runtime : hazardRuntimes) runtime.update(dt); updateSignals(dt); updatePlatforms(dt); @@ -769,14 +809,22 @@ public final class GameView extends View implements Runnable { } private void checkHazards() { - for (LevelDefinition.Hazard hazard : level.hazards) { - if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, hazard.box)) continue; + for (int index = 0; index < level.hazards.size(); index++) { + 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) { case SPIKES: kill(DeathType.SPIKE, hazard); return; case SAW: kill(DeathType.SAW, hazard); return; case FREEZER: kill(DeathType.FREEZE, hazard); return; case ELECTRIC: kill(DeathType.ELECTRIC, 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; emitHaptic(Build.VERSION.SDK_INT >= 27 ? HapticFeedbackConstants.LONG_PRESS : HapticFeedbackConstants.KEYBOARD_TAP); - audioEngine.play(AudioCue.DEATH_IMPACT); + audioEngine.play(hazardAudioCue(source)); audioEngine.play(AudioCue.DEATH_VOICE); 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() { if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, level.goal)) return; finishLevel(deaths, (long) (levelElapsed * 1000f)); @@ -1041,8 +1105,9 @@ public final class GameView extends View implements Runnable { } private void drawHazards(Canvas canvas) { - for (LevelDefinition.Hazard hazard : level.hazards) { - RectF box = rect(hazard.box); + for (int index = 0; index < level.hazards.size(); index++) { + LevelDefinition.Hazard hazard = level.hazards.get(index); + RectF box = rect(hazardRuntimes.get(index).currentBox()); switch (hazard.type) { case SPIKES: 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)); 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; + 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) { float angle = levelElapsed * 4.5f; @@ -1241,9 +1384,10 @@ public final class GameView extends View implements Runnable { float cardW = 310f; float cardH = 250f; float gapX = 65f; - for (int i = 0; i < LevelCatalog.all().size(); i++) { - int col = i % 5; - int row = i / 5; + for (int i = levelPage.firstIndex(); i < levelPage.endExclusive(); i++) { + int localIndex = i - levelPage.firstIndex(); + int col = localIndex % 5; + int row = localIndex / 5; float left = startX + col * (cardW + gapX); float top = 260f + row * 310f; RectF card = new RectF(left, top, left + cardW, top + cardH); @@ -1278,7 +1422,16 @@ public final class GameView extends View implements Runnable { canvas.restore(); } 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)); } @@ -1651,13 +1804,26 @@ public final class GameView extends View implements Runnable { refreshAccessibilityDescription(true); 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 cardW = 310f; float cardH = 250f; float gapX = 65f; - for (int i = 0; i < LevelCatalog.all().size(); i++) { - int col = i % 5; - int row = i / 5; + for (int i = levelPage.firstIndex(); i < levelPage.endExclusive(); i++) { + int localIndex = i - levelPage.firstIndex(); + int col = localIndex % 5; + int row = localIndex / 5; float left = startX + col * (cardW + gapX); float top = 260f + row * 310f; 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() { if (screen != Screen.COMPLETE) return; + levelPage.selectIndex(levelIndex); screen = Screen.LEVELS; pointers.clear(); deriveControls(); @@ -1906,6 +2073,24 @@ public final class GameView extends View implements Runnable { 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() { replayFromResult(); } @@ -1936,7 +2121,7 @@ public final class GameView extends View implements Runnable { if (screen == Screen.LEVELS) { visible.add(strings.projectTitle()); 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(); visible.add(enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked()); visible.add(strings.levelTitle(i + 1)); @@ -1948,6 +2133,10 @@ public final class GameView extends View implements Runnable { } } 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()); } else if (screen == Screen.SETTINGS) { visible.add(strings.settingsTitle()); @@ -2344,6 +2533,7 @@ public final class GameView extends View implements Runnable { startLevel(index); levelElapsed = snapshot.elapsedSeconds(); + for (HazardRuntime runtime : hazardRuntimes) runtime.update(levelElapsed); deaths = snapshot.deaths(); respawnTimer = snapshot.respawnSeconds(); previousSwitchContact.clear(); @@ -2436,6 +2626,16 @@ public final class GameView extends View implements Runnable { && player.supportIndex >= 0 && player.supportIndex < platformStates.size() && 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( screen == Screen.COMPLETE, player.alive, @@ -2457,7 +2657,10 @@ public final class GameView extends View implements Runnable { corpses.isEmpty() ? Float.NaN : corpses.get(0).x, player.onGround, player.vx, - player.vy + player.vy, + firstHazardActive, + firstHazardX, + firstHazardY ); } diff --git a/app/src/main/java/com/projectsacrifice/game/HazardRuntime.java b/app/src/main/java/com/projectsacrifice/game/HazardRuntime.java new file mode 100644 index 0000000..4d6dd96 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/HazardRuntime.java @@ -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; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/LevelPage.java b/app/src/main/java/com/projectsacrifice/game/LevelPage.java new file mode 100644 index 0000000..b2c0e5e --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/LevelPage.java @@ -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; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java index d3cf0d5..5959f41 100644 --- a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java +++ b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java @@ -257,6 +257,8 @@ public final class LevelPlaytestBot { + ", completed=" + snapshot.completed() + ", deaths=" + snapshot.deaths() + ", corpses=" + snapshot.corpseCount() + + ", firstCorpseX=" + snapshot.firstCorpseX() + + ", firstHazardX=" + snapshot.firstHazardX() + ", allDoorsOpen=" + snapshot.allDoorsOpen(); } diff --git a/app/src/main/java/com/projectsacrifice/game/ProceduralAudioEngine.java b/app/src/main/java/com/projectsacrifice/game/ProceduralAudioEngine.java index 63b9fb8..856013f 100644 --- a/app/src/main/java/com/projectsacrifice/game/ProceduralAudioEngine.java +++ b/app/src/main/java/com/projectsacrifice/game/ProceduralAudioEngine.java @@ -106,6 +106,18 @@ final class ProceduralAudioEngine { case DOOR: return variant == 0 ? ToneGenerator.TONE_CDMA_CONFIRM : ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD; 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 RESPAWN_VOICE: return variant == 0 ? ToneGenerator.TONE_CDMA_PIP : variant == 1 ? ToneGenerator.TONE_CDMA_CALL_SIGNAL_ISDN_NORMAL @@ -126,6 +138,10 @@ final class ProceduralAudioEngine { case SWITCH: return 85 + variant * 20; case DOOR: return 180; 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 RESPAWN_VOICE: return 175 + variant * 35; case GOAL_VOICE: return 320 + variant * 55; diff --git a/app/src/main/java/com/projectsacrifice/game/VersionedSessionCodec.java b/app/src/main/java/com/projectsacrifice/game/VersionedSessionCodec.java index 39f716d..3848fb2 100644 --- a/app/src/main/java/com/projectsacrifice/game/VersionedSessionCodec.java +++ b/app/src/main/java/com/projectsacrifice/game/VersionedSessionCodec.java @@ -64,7 +64,9 @@ public final class VersionedSessionCodec { if (version < 1) return result(Status.CORRUPT, null); 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) .saveVersion(CURRENT_VERSION) .elapsedSeconds(finiteNonNegative(input.readFloat())) diff --git a/app/src/test/java/com/projectsacrifice/game/DeterministicInputTapeTest.java b/app/src/test/java/com/projectsacrifice/game/DeterministicInputTapeTest.java index fd4d5cf..e7870c2 100644 --- a/app/src/test/java/com/projectsacrifice/game/DeterministicInputTapeTest.java +++ b/app/src/test/java/com/projectsacrifice/game/DeterministicInputTapeTest.java @@ -45,6 +45,15 @@ public final class DeterministicInputTapeTest { 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 int level; private long ticks; diff --git a/app/src/test/java/com/projectsacrifice/game/GameStringsTest.java b/app/src/test/java/com/projectsacrifice/game/GameStringsTest.java index 8ff7583..7e74feb 100644 --- a/app/src/test/java/com/projectsacrifice/game/GameStringsTest.java +++ b/app/src/test/java/com/projectsacrifice/game/GameStringsTest.java @@ -27,13 +27,16 @@ public final class GameStringsTest { assertEquals("Русский", russian.languageName()); assertEquals("Рыцарь-доброволец", russian.levelTitle(1)); 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.levelBestStats(2, 7_000L)); assertEquals("★ 2 / 3 добровольца", russian.completionDeaths(true, 2, 3)); assertEquals("Смерть — это инструмент", russian.tutorialWelcomeTitle()); 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.levelHint(level).trim().isEmpty()); assertNotEquals(english.levelTitle(level), russian.levelTitle(level)); diff --git a/app/src/test/java/com/projectsacrifice/game/HazardRuntimeTest.java b/app/src/test/java/com/projectsacrifice/game/HazardRuntimeTest.java new file mode 100644 index 0000000..b0e225a --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/HazardRuntimeTest.java @@ -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()); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java b/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java index 5934da8..66e079a 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelCatalogTest.java @@ -12,9 +12,9 @@ import java.util.Set; public final class LevelCatalogTest { @Test - public void verticalSliceContainsTenSequentialLevels() { + public void productionCatalogContainsFourHazardTeachingLevelsAfterTheVerticalSlice() { List levels = LevelCatalog.all(); - assertEquals(10, levels.size()); + assertEquals(14, levels.size()); Set titles = new HashSet<>(); for (int i = 0; i < levels.size(); i++) { LevelDefinition level = levels.get(i); @@ -22,6 +22,10 @@ public final class LevelCatalogTest { assertTrue(titles.add(level.title)); 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 diff --git a/app/src/test/java/com/projectsacrifice/game/LevelJsonLoaderTest.java b/app/src/test/java/com/projectsacrifice/game/LevelJsonLoaderTest.java index c011289..a41196a 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelJsonLoaderTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelJsonLoaderTest.java @@ -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) { return "{" + "\"formatVersion\":" + formatVersion + "," diff --git a/app/src/test/java/com/projectsacrifice/game/LevelPageTest.java b/app/src/test/java/com/projectsacrifice/game/LevelPageTest.java new file mode 100644 index 0000000..0cf5f46 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/LevelPageTest.java @@ -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()); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/VersionedSessionCodecTest.java b/app/src/test/java/com/projectsacrifice/game/VersionedSessionCodecTest.java index e083db5..f5d6807 100644 --- a/app/src/test/java/com/projectsacrifice/game/VersionedSessionCodecTest.java +++ b/app/src/test/java/com/projectsacrifice/game/VersionedSessionCodecTest.java @@ -58,6 +58,22 @@ public final class VersionedSessionCodecTest { 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() { return GameSessionSnapshot.builder(5) .elapsedSeconds(12.75f) diff --git a/docs/CURRENT_ROADMAP.md b/docs/CURRENT_ROADMAP.md index 73f3a30..f7e4674 100644 --- a/docs/CURRENT_ROADMAP.md +++ b/docs/CURRENT_ROADMAP.md @@ -178,16 +178,18 @@ baseline-задачи также имели явного владельца по - content не содержит blocker-softlocks и обязательных невосстановимых объектов; - принят `goon-game-milestone-production`. -Статус на 19 августа 2026: Stage 6 начат в изолированном production stream. +Статус на 19 августа 2026: Stage 6 выполняется в изолированном production stream. Завершены три foundation slice. `goon-game-character-states` добавил явные Idle/Run/Turn/Jump/Fall/Land/Interact/Stunned/Death transitions. `goon-game-corpse-manager` перенёс регистрацию, transforms, FIFO budget, reset, save/restore и diagnostics тел в отдельного владельца без изменения физики и геометрии. `goon-game-puzzle-events` выделил pressure/toggle/timed/relay channels, immutable events и явные DoorController states; двери и платформы читают общую -data-driven модель. Последний gate: 89 JVM-тестов и Android 16 matrix `38/38`. -Следующая итерация — production-каталог hazards (`crusher`, `fire`, projectile, -explosion), который затем даёт источники для новых corpse states. +data-driven модель. Завершён первый mechanics slice: production-каталог hazards +(`crusher`, `fire`, `projectile`, `explosion`), обучающие уровни 11–14, +динамическая пагинация каталога и 12/12 bot-прохождений. Последний gate: +99 JVM-тестов и Android 16 matrix `43/43` (`2004.172 s`). Следующая зависимая +итерация — новые corpse states на источниках production hazards. ### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней diff --git a/docs/LEVEL_SOLUTION_GRAPHS.md b/docs/LEVEL_SOLUTION_GRAPHS.md index 790f2b2..143f765 100644 --- a/docs/LEVEL_SOLUTION_GRAPHS.md +++ b/docs/LEVEL_SOLUTION_GRAPHS.md @@ -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 остаётся стабильным. +## Уровень 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. 3. Добавить параметризованные проверки restart из ключевых промежуточных состояний. 4. Повторить bot matrix на дополнительных frame rates и поддерживаемых версиях Android. diff --git a/docs/STAGE6_PRODUCTION_HAZARDS.md b/docs/STAGE6_PRODUCTION_HAZARDS.md new file mode 100644 index 0000000..32273e0 --- /dev/null +++ b/docs/STAGE6_PRODUCTION_HAZARDS.md @@ -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. diff --git a/docs/STAGE_REGISTRY.md b/docs/STAGE_REGISTRY.md index 2ed1d8f..f3d7eb7 100644 --- a/docs/STAGE_REGISTRY.md +++ b/docs/STAGE_REGISTRY.md @@ -65,7 +65,7 @@ Snapshot на 19 августа 2026 года: | 3 | 5 | 0 | | 4 | 4 | 0 | | 5 | 15 | 0 | -| 6 | 18 | 18 | +| 6 | 18 | 12 | | 7 | 9 | 9 | | 8 | 1 | 1 | | 9 | 1 | 1 | diff --git a/level-format/src/main/java/com/projectsacrifice/game/LevelDefinition.java b/level-format/src/main/java/com/projectsacrifice/game/LevelDefinition.java index d73b165..4173ca3 100644 --- a/level-format/src/main/java/com/projectsacrifice/game/LevelDefinition.java +++ b/level-format/src/main/java/com/projectsacrifice/game/LevelDefinition.java @@ -6,7 +6,9 @@ import java.util.List; /** Immutable, Android-independent level data. */ 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 static final class Box { @@ -54,17 +56,36 @@ public final class LevelDefinition { public final String relayId; public final float impulseX; 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) { this(type, box, null, 0f, 0f); } 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.box = box; this.relayId = relayId; this.impulseX = impulseX; this.impulseY = impulseY; + this.cycleSeconds = cycleSeconds; + this.activeSeconds = activeSeconds; + this.speedX = speedX; + this.speedY = speedY; + this.visualStyle = visualStyle; + this.audioCue = audioCue; } } diff --git a/level-format/src/main/java/com/projectsacrifice/game/LevelJsonLoader.java b/level-format/src/main/java/com/projectsacrifice/game/LevelJsonLoader.java index 3d6272f..4fb284d 100644 --- a/level-format/src/main/java/com/projectsacrifice/game/LevelJsonLoader.java +++ b/level-format/src/main/java/com/projectsacrifice/game/LevelJsonLoader.java @@ -106,7 +106,13 @@ public final class LevelJsonLoader { box(required(value, "box", path), path + ".box"), optionalText(value, "relayId", path), 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; } diff --git a/level-format/src/main/java/com/projectsacrifice/game/LevelValidator.java b/level-format/src/main/java/com/projectsacrifice/game/LevelValidator.java index ff791a1..f1b43f5 100644 --- a/level-format/src/main/java/com/projectsacrifice/game/LevelValidator.java +++ b/level-format/src/main/java/com/projectsacrifice/game/LevelValidator.java @@ -40,7 +40,47 @@ public final class LevelValidator { } } 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++) { 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"); } + 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) { return new IllegalArgumentException(path + " " + message); } diff --git a/level-format/src/main/resources/levels/index.json b/level-format/src/main/resources/levels/index.json index 5a10188..df78b25 100644 --- a/level-format/src/main/resources/levels/index.json +++ b/level-format/src/main/resources/levels/index.json @@ -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"]} diff --git a/level-format/src/main/resources/levels/level-11.json b/level-format/src/main/resources/levels/level-11.json new file mode 100644 index 0000000..928085e --- /dev/null +++ b/level-format/src/main/resources/levels/level-11.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":[] +} diff --git a/level-format/src/main/resources/levels/level-12.json b/level-format/src/main/resources/levels/level-12.json new file mode 100644 index 0000000..bca4069 --- /dev/null +++ b/level-format/src/main/resources/levels/level-12.json @@ -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":[] +} diff --git a/level-format/src/main/resources/levels/level-13.json b/level-format/src/main/resources/levels/level-13.json new file mode 100644 index 0000000..5449bf7 --- /dev/null +++ b/level-format/src/main/resources/levels/level-13.json @@ -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":[] +} diff --git a/level-format/src/main/resources/levels/level-14.json b/level-format/src/main/resources/levels/level-14.json new file mode 100644 index 0000000..ff0b47d --- /dev/null +++ b/level-format/src/main/resources/levels/level-14.json @@ -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"]}] +}