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

This commit is contained in:
2026-08-19 05:30:56 +03:00
parent cc7f487cf7
commit dc2ebcbf8c
33 changed files with 1160 additions and 37 deletions
+2 -2
View File
@@ -60,7 +60,7 @@
{"_type":"issue","id":"goon-game-e08","title":"EPIC-08 UI/UX","description":"Android controls, HUD, меню, выбор уровней и доступность. Разделы 16–17, 31–33, 45.","acceptance_criteria":"Игра полностью понятна и управляема на поддерживаемых экранах без внешних объяснений.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:59Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:29Z","metadata":{"stages":"0,3,6","primary_stage":6},"labels":["epic","primary-stage-6","stage-0","stage-3","stage-6","ui","ux"],"dependencies":[{"issue_id":"goon-game-e08","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:50Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-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}
+6 -3
View File
@@ -107,10 +107,13 @@ invented session data in `docs/STAGE5_HUMAN_PLAYTEST_RESULT.md`. Stage 5 is acce
Stage 6 production work has started. Its three completed foundation slices add a
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:
@@ -0,0 +1,64 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.os.SystemClock;
import android.view.MotionEvent;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.List;
@RunWith(AndroidJUnit4.class)
public final class LevelPaginationTest {
@Test
public void nextAndPreviousControlsExposeEveryAuthoredLevel() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
view.setLanguageForAutomation(GameLanguage.EN);
assertEquals(0, view.levelPageIndexForAutomation());
assertEquals(2, view.levelPageCountForAutomation());
assertTrue(view.visibleLocalizedTextForAutomation().contains("The Volunteer"));
assertFalse(view.visibleLocalizedTextForAutomation().contains("Steam Charge"));
});
tap(activity, 1_730f, 965f);
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
List<String> visible = view.visibleLocalizedTextForAutomation();
assertEquals(1, view.levelPageIndexForAutomation());
assertTrue(visible.contains("Steam Charge"));
assertFalse(visible.contains("The Volunteer"));
assertTrue(visible.contains("Page 2 of 2"));
});
tap(activity, 190f, 965f);
activity.onActivity(current ->
assertEquals(0, current.gameViewForAutomation().levelPageIndexForAutomation()));
}
}
private static void tap(ActivityScenario<MainActivity> activity, float canvasX, float canvasY) {
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
float scale = Math.min(view.getWidth() / 1_920f, view.getHeight() / 1_080f);
float x = (view.getWidth() - 1_920f * scale) * 0.5f + canvasX * scale;
float y = (view.getHeight() - 1_080f * scale) * 0.5f + canvasY * scale;
long now = SystemClock.uptimeMillis();
MotionEvent down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0);
view.dispatchTouchEvent(down);
down.recycle();
MotionEvent up = MotionEvent.obtain(now, now, MotionEvent.ACTION_UP, x, y, 0);
view.dispatchTouchEvent(up);
up.recycle();
});
}
}
@@ -0,0 +1,94 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.app.Instrumentation;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public final class ProductionHazardPlaythroughTest {
private static final int CONSECUTIVE_RUNS = 3;
@Test public void crusherLevelCompletesThreeTimes() {
prove(ProductionHazardScenarios.levelEleven(), 35_000L, false);
}
@Test public void fireLevelCompletesThreeTimes() {
prove(ProductionHazardScenarios.levelTwelve(), 35_000L, false);
}
@Test public void projectileLevelCompletesThreeTimes() {
prove(ProductionHazardScenarios.levelThirteen(), 40_000L, false);
}
@Test public void explosionDeliversBodyAndOpensGateThreeTimes() {
prove(ProductionHazardScenarios.levelFourteen(), 45_000L, true);
}
private static void prove(LevelPlaytestBot.Scenario scenario, long budget,
boolean requiresDoor) {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
AndroidDriver driver = AndroidDriver.from(activity);
for (int run = 1; run <= CONSECUTIVE_RUNS; run++) {
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, budget);
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
assertEquals("run=" + run + "; teaching route uses exactly one volunteer",
1, result.deaths());
assertEquals("run=" + run + "; sacrificed body remains in the level",
1, result.corpseCount());
if (requiresDoor) {
assertTrue("run=" + run + "; blast-delivered body must open the gate",
result.doorOpened());
}
}
}
}
private static final class AndroidDriver implements LevelPlaytestBot.Driver {
private final Instrumentation instrumentation;
private final GameView gameView;
private AndroidDriver(Instrumentation instrumentation, GameView gameView) {
this.instrumentation = instrumentation;
this.gameView = gameView;
}
static AndroidDriver from(ActivityScenario<MainActivity> activity) {
GameView[] view = new GameView[1];
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
return new AndroidDriver(InstrumentationRegistry.getInstrumentation(), view[0]);
}
@Override public void startLevel(int oneBasedLevelNumber) {
instrumentation.runOnMainSync(
() -> gameView.startLevelForAutomation(oneBasedLevelNumber));
}
@Override public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
instrumentation.runOnMainSync(
() -> gameView.setAutomationInput(moveLeft, moveRight, jump));
}
@Override public GameView.AutomationSnapshot snapshot() {
GameView.AutomationSnapshot[] snapshot = new GameView.AutomationSnapshot[1];
instrumentation.runOnMainSync(() -> snapshot[0] = gameView.automationSnapshot());
return snapshot[0];
}
@Override public void waitForNextDecision(long millis) {
try {
Thread.sleep(Math.max(32L, millis));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new AssertionError("Production-hazard bot interrupted", interrupted);
}
}
}
}
@@ -0,0 +1,143 @@
package com.projectsacrifice.game;
/** Intended routes for the first four data-driven production hazards. */
final class ProductionHazardScenarios {
private ProductionHazardScenarios() {}
static LevelPlaytestBot.Scenario levelEleven() {
return LevelPlaytestBot.Scenario.builder(11, "Level 11 - crusher rhythm")
.step("Sacrifice one volunteer to the press", 4_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Approach the retained body", 2_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
.step("Observe the press close for a fresh cycle", 4_000L,
LevelPlaytestBot.Input.none(),
GameView.AutomationSnapshot::firstHazardActive)
.step("Observe the press resting", 4_000L, LevelPlaytestBot.Input.none(),
snapshot -> !snapshot.firstHazardActive())
.step("Jump through the resting press", 2_500L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 900f))
.step("Reach the relic", 6_000L, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed)
.build();
}
static LevelPlaytestBot.Scenario levelTwelve() {
return LevelPlaytestBot.Scenario.builder(12, "Level 12 - blue fire")
.step("Sacrifice one volunteer to the flame", 4_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Approach the blue flame", 2_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
LevelPlaytestBot.groundedAliveAtOrBeyond(535f)))
.step("Take off before the flame", 1_000L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround()
&& snapshot.playerY() <= 700f))
.step("Clear the flame", 2_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 850f))
.step("Reach the relic", 6_000L, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed)
.build();
}
static LevelPlaytestBot.Scenario levelThirteen() {
return LevelPlaytestBot.Scenario.builder(13, "Level 13 - clockwork projectile")
.step("Read the bolt by sacrificing once", 5_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Approach the firing line", 2_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
LevelPlaytestBot.groundedAliveAtOrBeyond(600f)))
.step("Wait for the jump window", 4_000L, LevelPlaytestBot.Input.none(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.firstHazardX() >= 850f
&& snapshot.firstHazardX() <= 930f))
.step("Jump through the bolt line", 1_500L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerY() <= 690f))
.step("Clear the firing line", 2_500L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 1_050f))
.step("Wait for the returning bolt", 4_000L, LevelPlaytestBot.Input.none(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.firstHazardX() >= 1_350f
&& snapshot.firstHazardX() <= 1_450f))
.step("Jump through the returning bolt", 1_500L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerY() <= 690f))
.step("Clear the second firing line", 2_000L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 1_600f))
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
GameView.AutomationSnapshot::completed))
.build();
}
static LevelPlaytestBot.Scenario levelFourteen() {
return LevelPlaytestBot.Scenario.builder(14, "Level 14 - explosion delivery")
.step("Give one volunteer to the blast", 4_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
.step("Observe the delivered body open the gate", 5_000L,
LevelPlaytestBot.Input.none(),
GameView.AutomationSnapshot::allDoorsOpen)
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
.step("Approach the steam charge", 2_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
LevelPlaytestBot.groundedAliveAtOrBeyond(390f)))
.step("Observe the charge rebuild", 4_000L,
LevelPlaytestBot.Input.none(),
GameView.AutomationSnapshot::firstHazardActive)
.step("Observe the safe blast rest", 4_000L,
LevelPlaytestBot.Input.none(),
snapshot -> !snapshot.firstHazardActive())
.step("Take off before the blast", 1_000L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround()
&& snapshot.playerY() <= 690f))
.step("Clear the blast", 2_000L, LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 820f))
.step("Approach the delivered body", 1_000L,
LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 880f)
.step("Jump over the delivered body", 2_000L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1,
snapshot -> snapshot.playerAlive()
&& snapshot.playerX() >= 1_200f))
.step("Pass the opened gate", 4_000L, LevelPlaytestBot.Input.right(),
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_550f)
.step("Reach the relic", 5_000L, LevelPlaytestBot.Input.right(),
GameView.AutomationSnapshot::completed)
.build();
}
}
@@ -8,6 +8,10 @@ public enum AudioCue {
SWITCH(Channel.EFFECTS, 3),
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),
@@ -40,7 +40,7 @@ public final class DeterministicInputTape {
private final ArrayList<Command> 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;
@@ -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() ? "Нажмите на строку, чтобы изменить. Всё сохраняется автоматически."
@@ -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<Body> corpses = corpseManager.bodies();
private final ArrayList<PlatformState> platformStates = new ArrayList<>();
private final ArrayList<HazardRuntime> hazardRuntimes = new ArrayList<>();
private final Map<String, Boolean> previousSwitchContact = new HashMap<>();
private final Map<LevelDefinition.Door, DoorController> 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
);
}
@@ -0,0 +1,47 @@
package com.projectsacrifice.game;
/** Deterministic per-level runtime clock for one immutable hazard definition. */
public final class HazardRuntime {
private final LevelDefinition.Hazard definition;
private float elapsedSeconds;
public HazardRuntime(LevelDefinition.Hazard definition) {
if (definition == null) throw new IllegalArgumentException("definition is required");
this.definition = definition;
}
public LevelDefinition.Hazard definition() { return definition; }
public float elapsedSeconds() { return elapsedSeconds; }
public void update(float deltaSeconds) {
if (!Float.isFinite(deltaSeconds) || deltaSeconds < 0f) {
throw new IllegalArgumentException("deltaSeconds must be finite and non-negative");
}
elapsedSeconds += deltaSeconds;
if (definition.cycleSeconds > 0f && elapsedSeconds >= definition.cycleSeconds) {
elapsedSeconds %= definition.cycleSeconds;
}
}
public boolean active() {
boolean cyclicWindow = definition.type == LevelDefinition.HazardType.CRUSHER
|| definition.type == LevelDefinition.HazardType.EXPLOSION;
if (!cyclicWindow || definition.cycleSeconds <= 0f
|| definition.activeSeconds <= 0f) return true;
return elapsedSeconds < definition.activeSeconds;
}
public LevelDefinition.Box currentBox() {
if (definition.type != LevelDefinition.HazardType.PROJECTILE) {
return definition.box;
}
return new LevelDefinition.Box(
definition.box.x + definition.speedX * elapsedSeconds,
definition.box.y + definition.speedY * elapsedSeconds,
definition.box.width, definition.box.height);
}
public void reset() {
elapsedSeconds = 0f;
}
}
@@ -0,0 +1,61 @@
package com.projectsacrifice.game;
public final class LevelPage {
private final int totalItems;
private final int pageSize;
private int pageIndex;
public LevelPage(int totalItems, int pageSize) {
if (totalItems < 1) {
throw new IllegalArgumentException("totalItems must be positive");
}
if (pageSize < 1) {
throw new IllegalArgumentException("pageSize must be positive");
}
this.totalItems = totalItems;
this.pageSize = pageSize;
}
public int pageIndex() {
return pageIndex;
}
public int pageCount() {
return (totalItems + pageSize - 1) / pageSize;
}
public int firstIndex() {
return pageIndex * pageSize;
}
public int endExclusive() {
return Math.min(totalItems, firstIndex() + pageSize);
}
public boolean hasPrevious() {
return pageIndex > 0;
}
public boolean hasNext() {
return pageIndex + 1 < pageCount();
}
public void previous() {
if (hasPrevious()) {
pageIndex--;
}
}
public void next() {
if (hasNext()) {
pageIndex++;
}
}
public void selectIndex(int index) {
if (index < 0 || index >= totalItems) {
throw new IllegalArgumentException("index outside level page");
}
pageIndex = index / pageSize;
}
}
@@ -257,6 +257,8 @@ public final class LevelPlaytestBot {
+ ", completed=" + snapshot.completed()
+ ", deaths=" + snapshot.deaths()
+ ", corpses=" + snapshot.corpseCount()
+ ", firstCorpseX=" + snapshot.firstCorpseX()
+ ", firstHazardX=" + snapshot.firstHazardX()
+ ", allDoorsOpen=" + snapshot.allDoorsOpen();
}
@@ -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;
@@ -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()))
@@ -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;
@@ -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));
@@ -0,0 +1,59 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public final class HazardRuntimeTest {
@Test
public void crusherActiveWindowRepeatsOnItsDataDrivenCycle() {
LevelDefinition.Hazard definition = new LevelDefinition.Hazard(
LevelDefinition.HazardType.CRUSHER,
new LevelDefinition.Box(620f, 690f, 150f, 190f),
null, 0f, 180f, 4f, 1.25f, 0f, 0f,
"brass-press", "crusher-impact");
HazardRuntime runtime = new HazardRuntime(definition);
assertTrue(runtime.active());
runtime.update(1.2f);
assertTrue(runtime.active());
runtime.update(0.1f);
assertFalse(runtime.active());
runtime.update(2.7f);
assertTrue(runtime.active());
runtime.update(4f);
assertTrue(runtime.active());
}
@Test
public void projectileMovesFromItsSpawnBoxAndWrapsAtTheConfiguredCycle() {
LevelDefinition.Hazard definition = new LevelDefinition.Hazard(
LevelDefinition.HazardType.PROJECTILE,
new LevelDefinition.Box(620f, 790f, 48f, 48f),
null, 420f, -120f, 3f, 0f, 360f, 0f,
"clockwork-bolt", "projectile-hit");
HazardRuntime runtime = new HazardRuntime(definition);
runtime.update(1f);
assertTrue(runtime.currentBox().x == 980f);
runtime.update(2f);
assertTrue(runtime.currentBox().x == 620f);
}
@Test
public void explosionHasADataDrivenBlastAndRestWindow() {
LevelDefinition.Hazard definition = new LevelDefinition.Hazard(
LevelDefinition.HazardType.EXPLOSION,
new LevelDefinition.Box(620f, 740f, 140f, 140f),
null, 550f, -500f, 4f, 2f, 0f, 0f,
"steam-blast", "explosion-blast");
HazardRuntime runtime = new HazardRuntime(definition);
assertTrue(runtime.active());
runtime.update(2.1f);
assertFalse(runtime.active());
runtime.update(1.9f);
assertTrue(runtime.active());
}
}
@@ -12,9 +12,9 @@ import java.util.Set;
public final class LevelCatalogTest {
@Test
public void verticalSliceContainsTenSequentialLevels() {
public void productionCatalogContainsFourHazardTeachingLevelsAfterTheVerticalSlice() {
List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(10, levels.size());
assertEquals(14, levels.size());
Set<String> 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
@@ -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 + ","
@@ -0,0 +1,33 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public final class LevelPageTest {
@Test
public void fourteenLevelsUseTwoBoundedTenCardPages() {
LevelPage page = new LevelPage(14, 10);
assertEquals(2, page.pageCount());
assertEquals(0, page.firstIndex());
assertEquals(10, page.endExclusive());
assertFalse(page.hasPrevious());
assertTrue(page.hasNext());
page.next();
assertEquals(10, page.firstIndex());
assertEquals(14, page.endExclusive());
assertTrue(page.hasPrevious());
assertFalse(page.hasNext());
page.next();
assertEquals(1, page.pageIndex());
page.selectIndex(3);
assertEquals(0, page.pageIndex());
page.selectIndex(13);
assertEquals(1, page.pageIndex());
}
}
@@ -58,6 +58,22 @@ public final class VersionedSessionCodecTest {
assertNull(future.snapshot());
}
@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)
+6 -4
View File
@@ -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 дней
+76 -1
View File
@@ -285,9 +285,84 @@ Goal расположен на верхнем уступе за PIT и закр
**Softlock/неопределённость:** исправлены два независимых blocker-состояния: saw-тело больше не соскальзывает с плиты после доставки, а перекрытие floor/lift увеличено до 140 px при сохранении 200 px `PIT` и обязательного вертикального подъёма. Период lift увеличен до `10.0 s`, чтобы нижняя фаза была читаемой при задержанном вводе. Бот теперь явно приземляется на frozen corpse и прыгает с него; доставка saw-тела, три сохранённых тела, посадка, подъём и lift→ledge прыжок подтверждены тремя запусками и стресс-сценарием. Возможная альтернативная двухсмертная трасса не считается blocker, пока intended three-body route остаётся стабильным.
## Уровень 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.
+68
View File
@@ -0,0 +1,68 @@
# Stage 6 production hazards
Date: 19 August 2026
Beads task: `goon-game-hazards-full`
## Delivered contract
- JSON hazard definitions now support crusher, fire, projectile and explosion
types with data-bound cycle, active window, velocity, visual style and audio cue.
- `HazardRuntime` owns deterministic cycle state and moving projectile geometry.
- `GameView` collision, drawing, procedural audio and restore consume the same
runtime definition; no hazard timing is duplicated in the bot.
- Four focused teaching levels extend the catalog from 10 to 14.
- The level-select UI pages an arbitrary catalog in groups of ten and exposes all
four new levels through real touch controls and RU/EN copy.
- Tape/session validation derives the upper level bound from `LevelCatalog`
instead of a hard-coded ten-level limit.
## Teaching routes
- Level 11: sacrifice to a rhythmic crusher, then cross during a fresh REST window.
- Level 12: sacrifice to fire, then take off from the safe staging point and clear it.
- Level 13: read and evade two consecutive projectile cycles after one sacrifice.
- Level 14: explosion-deliver a body to a pressure plate, cross the next REST
window, jump the retained body and pass the opened door.
Every route uses production input and read-only snapshots. It has no restart,
retry, teleport or direct completion mutation and asserts exactly one death and
one retained body. Level 14 additionally requires the door-open event.
## TDD evidence
RED cycles covered missing schema symbols, missing runtime, each new hazard type,
the old hard-coded ten-level tape/session bound, missing level pagination and
missing intended-route scenarios. Device REDs then exposed:
- a crusher crossing begun midway through its rest interval;
- a fire jump staged too early and descending into the far edge;
- a second projectile cycle not handled by the initial route;
- an explosion impulse that overshot its required plate;
- an always-active explosion box that could not be cleared with release physics;
- the retained level-14 body correctly blocking the living player at the plate.
Each was resolved at the narrowest responsible layer: state-driven choreography,
authored explosion impulse/cycle, and an explicit jump over the retained body.
## Focused acceptance
- Validator: `14/14`, format `1`, physics `1`.
- Level 11: `3/3`, `46.414 s`.
- Level 12: `3/3`, `41.143 s`.
- Level 13: `3/3`, `44.630 s`.
- Level 14: `3/3`, `52.089 s`.
- Combined one-install matrix: four routes, twelve playthroughs plus touch
pagination, `5/5` test methods, `221.894 s`.
## Final gates
- JVM: `99`, failures `0`, errors `0`, skipped `0`.
- `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN from clean.
- final unfiltered Android 16 matrix: `43/43`, failures `0`, `2004.172 s`, on
the exact clean-built final app/test APK.
- debug APK ZIP integrity: `unzip -t` GREEN and contains all 14 JSON levels.
- debug APK SHA-256:
`f220ada1da1e32bbca15615487f49a1ac3b61d924af292b648b6693a14d5f212`.
The dedicated AVD was stopped after the gate.
+1 -1
View File
@@ -65,7 +65,7 @@ Snapshot на 19 августа 2026 года:
| 3 | 5 | 0 |
| 4 | 4 | 0 |
| 5 | 15 | 0 |
| 6 | 18 | 18 |
| 6 | 18 | 12 |
| 7 | 9 | 9 |
| 8 | 1 | 1 |
| 9 | 1 | 1 |
@@ -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;
}
}
@@ -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;
}
@@ -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);
}
@@ -1 +1 @@
{"formatVersion":1,"physicsVersion":1,"levels":["level-01.json","level-02.json","level-03.json","level-04.json","level-05.json","level-06.json","level-07.json","level-08.json","level-09.json","level-10.json"]}
{"formatVersion":1,"physicsVersion":1,"levels":["level-01.json","level-02.json","level-03.json","level-04.json","level-05.json","level-06.json","level-07.json","level-08.json","level-09.json","level-10.json","level-11.json","level-12.json","level-13.json","level-14.json"]}
@@ -0,0 +1,11 @@
{
"formatVersion":1,"physicsVersion":1,"number":11,
"copy":{"en":{"title":"The Brass Breather","hint":"The press has a rhythm. Leave one volunteer, then cross while its jaws rest."},"ru":{"title":"Латунный пресс","hint":"У пресса есть ритм. Оставьте одного добровольца и пройдите, пока створки отдыхают."}},
"world":{"width":2400,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2180,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":35},
"platforms":[{"box":{"x":0,"y":880,"width":2400,"height":200}}],
"hazards":[{"type":"CRUSHER","box":{"x":700,"y":690,"width":150,"height":190},
"impulseX":80,"impulseY":120,"cycleSeconds":4,"activeSeconds":2.5,
"speedX":0,"speedY":0,"visualStyle":"brass-press","audioCue":"crusher-impact"}],
"switches":[],"doors":[]
}
@@ -0,0 +1,11 @@
{
"formatVersion":1,"physicsVersion":1,"number":12,
"copy":{"en":{"title":"Blue Funeral","hint":"Flame is final for the living. Sacrifice once, then jump the blue fire."},"ru":{"title":"Синее пламя","hint":"Огонь смертелен для живых. Пожертвуйте одним и перепрыгните синее пламя."}},
"world":{"width":2400,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2180,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":35},
"platforms":[{"box":{"x":0,"y":880,"width":2400,"height":200}}],
"hazards":[{"type":"FIRE","box":{"x":700,"y":790,"width":120,"height":90},
"impulseX":160,"impulseY":-220,"cycleSeconds":0,"activeSeconds":0,
"speedX":0,"speedY":0,"visualStyle":"blue-flame","audioCue":"fire-burst"}],
"switches":[],"doors":[]
}
@@ -0,0 +1,11 @@
{
"formatVersion":1,"physicsVersion":1,"number":13,
"copy":{"en":{"title":"Crossfire Clock","hint":"The clockwork bolt always returns. Read its line, then jump through the gap."},"ru":{"title":"Часовой обстрел","hint":"Заводной снаряд всегда возвращается. Прочитайте траекторию и прыгните в окно."}},
"world":{"width":2400,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2180,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":40},
"platforms":[{"box":{"x":0,"y":880,"width":2400,"height":200}}],
"hazards":[{"type":"PROJECTILE","box":{"x":1700,"y":790,"width":48,"height":48},
"impulseX":420,"impulseY":-120,"cycleSeconds":3,"activeSeconds":0,
"speedX":-400,"speedY":0,"visualStyle":"clockwork-bolt","audioCue":"projectile-hit"}],
"switches":[],"doors":[]
}
@@ -0,0 +1,13 @@
{
"formatVersion":1,"physicsVersion":1,"number":14,
"copy":{"en":{"title":"Steam Charge","hint":"The blast throws what it takes. Deliver a body to the plate and use the open gate."},"ru":{"title":"Паровой заряд","hint":"Взрыв отбрасывает жертву. Доставьте тело на плиту и пройдите в открытые ворота."}},
"world":{"width":2500,"height":1080},"spawn":{"x":130,"y":760},
"goal":{"x":2260,"y":770,"width":86,"height":110},"targets":{"deaths":1,"seconds":45},
"platforms":[{"box":{"x":0,"y":880,"width":2500,"height":200}}],
"hazards":[{"type":"EXPLOSION","box":{"x":620,"y":740,"width":140,"height":140},
"impulseX":550,"impulseY":-500,"cycleSeconds":4,"activeSeconds":2,
"speedX":0,"speedY":0,"visualStyle":"steam-blast","audioCue":"explosion-blast"}],
"switches":[{"type":"PRESSURE","box":{"x":980,"y":860,"width":180,"height":20},
"signalId":"blast-gate","durationSeconds":0,"requiredMass":1}],
"doors":[{"box":{"x":1400,"y":560,"width":82,"height":320},"requiredSignals":["blast-gate"]}]
}