diff --git a/README.md b/README.md index e6b7a67..9a20ccf 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,13 @@ Death does not reset the level. A new volunteer respawns after a short delay whi - `Restart` restores the current level. - `II` pauses the game and exposes level selection. - `Settings` on the level-select screen cycles three control sizes, three - opacity presets, and vibration on/off. Changes persist across app restarts. + opacity presets, vibration on/off, Standard/Large text, high contrast, and + English/Russian. Changes persist across app restarts without resetting progress. + +The first level includes a three-step, non-blocking tutorial for movement, +multitouch jumping, and using retained bodies. Completion shows the current run, +saved best, targets, a next goal, and Level select/Replay/Next level routes. Critical +signal, door, and respawn state remains visible when vibration is disabled. The movement constants include 130 ms coyote time and 140 ms input buffering. @@ -59,6 +65,10 @@ matrix with: ./gradlew connectedDebugAndroidTest ``` +The Stage 3 device suite additionally checks persisted controls, RU/EN copy, +accessibility presets, tutorial/result navigation, Back behavior, and safe viewports +on 20:9, 16:9, and 16:10 tablet-sized displays. + 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: ```sh diff --git a/app/build.gradle b/app/build.gradle index ca520fa..16efb37 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { applicationId 'com.projectsacrifice.game' minSdk 23 targetSdk 34 - versionCode 6 - versionName '0.5.0-stage3-controls' + versionCode 7 + versionName '0.6.0-stage3-complete' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } diff --git a/app/src/androidTest/java/com/projectsacrifice/game/AccessibilityPersistenceTest.java b/app/src/androidTest/java/com/projectsacrifice/game/AccessibilityPersistenceTest.java new file mode 100644 index 0000000..0ea1dde --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/AccessibilityPersistenceTest.java @@ -0,0 +1,166 @@ +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; + +@RunWith(AndroidJUnit4.class) +public final class AccessibilityPersistenceTest { + @Test + public void accessibilityAndControlChoicesSurviveActivityRecreationTogether() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + view.resetControlSettingsForAutomation(); + view.resetAccessibilitySettingsForAutomation(); + view.selectNextControlSizeForAutomation(); + view.selectNextTextSizeForAutomation(); + view.toggleHighContrastForAutomation(); + }); + + activity.recreate(); + + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + assertEquals("Large", view.controlSettingsForAutomation().controlSizeLabel()); + assertEquals("Large", view.accessibilitySettingsForAutomation().textSizeLabel()); + assertTrue(view.accessibilitySettingsForAutomation().highContrastEnabled()); + view.resetControlSettingsForAutomation(); + view.resetAccessibilitySettingsForAutomation(); + }); + } + } + + @Test + public void descriptionsFollowScreensWhileBackAlwaysHasOneDeterministicResult() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(current -> assertContains( + current.gameViewForAutomation(), "Level select")); + + activity.onActivity(current -> current.gameViewForAutomation() + .openSettingsForAutomation()); + activity.onActivity(current -> { + assertContains(current.gameViewForAutomation(), "Settings"); + assertContains(current.gameViewForAutomation(), "Text size"); + current.onBackPressed(); + assertContains(current.gameViewForAutomation(), "Level select"); + }); + + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + view.startLevelForAutomation(1); + assertContains(view, "Playing level 1"); + view.pauseForAutomation(); + assertContains(view, "Paused on level 1"); + current.onBackPressed(); + assertContains(view, "Playing level 1"); + assertFalse(view.automationSnapshot().visiblyPaused()); + view.showCompleteForAutomation(); + assertContains(view, "Level complete"); + current.onBackPressed(); + assertContains(view, "Level select"); + }); + } + } + + @Test + public void textAndContrastPresetsDoNotMoveContentOutsideTheSafeViewport() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + float[] before = view.safeViewportForAutomation(); + view.selectNextTextSizeForAutomation(); + view.toggleHighContrastForAutomation(); + float[] after = view.safeViewportForAutomation(); + + assertTrue(before[0] >= 0f); + assertTrue(before[1] >= 0f); + assertTrue(before[2] <= view.getWidth()); + assertTrue(before[3] <= view.getHeight()); + assertEquals(before[0], after[0], 0f); + assertEquals(before[1], after[1], 0f); + assertEquals(before[2], after[2], 0f); + assertEquals(before[3], after[3], 0f); + view.resetAccessibilitySettingsForAutomation(); + }); + } + } + + @Test + public void accessibilityRowsChangeThePersistedSettingsThroughRealTouchInput() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + view.resetControlSettingsForAutomation(); + view.toggleHapticsForAutomation(); + view.resetAccessibilitySettingsForAutomation(); + view.openSettingsForAutomation(); + }); + + tap(activity, 220f, 345f); + tap(activity, 220f, 515f); + + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + assertEquals(1.25f, view.textScaleForAutomation(), 0f); + assertTrue(view.highContrastForAutomation()); + assertTrue(view.screenDescriptionForAutomation().contains("Text size Large")); + assertTrue(view.screenDescriptionForAutomation().contains("High contrast On")); + view.resetControlSettingsForAutomation(); + view.resetAccessibilitySettingsForAutomation(); + }); + } + } + + @Test + public void criticalSignalAndDoorStateRemainsTextualWhenVibrationIsOff() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + view.resetControlSettingsForAutomation(); + view.toggleHapticsForAutomation(); + view.startLevelForAutomation(3); + + assertEquals("Signals 0/1 · Doors 0/1 · Volunteer active", + view.criticalVisibleStatusForAutomation()); + assertTrue(view.screenDescriptionForAutomation().contains( + view.criticalVisibleStatusForAutomation())); + view.resetControlSettingsForAutomation(); + }); + } + } + + private static void tap(ActivityScenario activity, float canvasX, float canvasY) { + touch(activity, MotionEvent.ACTION_DOWN, canvasX, canvasY); + touch(activity, MotionEvent.ACTION_UP, canvasX, canvasY); + } + + private static void touch(ActivityScenario activity, int action, + 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 event = MotionEvent.obtain(now, now, action, x, y, 0); + view.dispatchTouchEvent(event); + event.recycle(); + }); + } + + private static void assertContains(GameView view, String expected) { + String description = String.valueOf(view.getContentDescription()); + assertTrue("Expected '" + expected + "' in '" + description + "'", + description.contains(expected)); + } +} diff --git a/app/src/androidTest/java/com/projectsacrifice/game/ControlSettingsPersistenceTest.java b/app/src/androidTest/java/com/projectsacrifice/game/ControlSettingsPersistenceTest.java index d22f237..c94b5e0 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/ControlSettingsPersistenceTest.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/ControlSettingsPersistenceTest.java @@ -22,13 +22,13 @@ public final class ControlSettingsPersistenceTest { try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { activity.onActivity(current -> current.gameViewForAutomation() .resetControlSettingsForAutomation()); - - tap(activity, 1_700f, 115f); + activity.onActivity(current -> current.gameViewForAutomation() + .openSettingsForAutomation()); activity.onActivity(current -> assertTrue(current.gameViewForAutomation() .settingsVisibleForAutomation())); + tap(activity, 1_250f, 685f); tap(activity, 1_250f, 345f); tap(activity, 1_250f, 515f); - tap(activity, 1_250f, 685f); activity.recreate(); diff --git a/app/src/androidTest/java/com/projectsacrifice/game/GameLanguagePersistenceTest.java b/app/src/androidTest/java/com/projectsacrifice/game/GameLanguagePersistenceTest.java new file mode 100644 index 0000000..45bc1a9 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/GameLanguagePersistenceTest.java @@ -0,0 +1,89 @@ +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; + +@RunWith(AndroidJUnit4.class) +public final class GameLanguagePersistenceTest { + @Test + public void settingsLanguageSwitchSurvivesRecreationWithoutChangingProgressOrControls() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + int[] expectedUnlocked = new int[1]; + int[] expectedBestDeaths = new int[1]; + long[] expectedBestTime = new long[1]; + + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + view.resetLanguageForAutomation(); + view.resetControlSettingsForAutomation(); + view.selectNextControlSizeForAutomation(); + view.selectNextControlOpacityForAutomation(); + view.toggleHapticsForAutomation(); + + ProgressStore progress = new ProgressStore(current); + progress.recordCompletion(2, 4, 22_222L); + expectedUnlocked[0] = progress.unlockedCount(); + expectedBestDeaths[0] = progress.bestDeaths(2); + expectedBestTime[0] = progress.bestTimeMs(2); + }); + + tap(activity, 1_700f, 115f); // Settings from level select. + activity.onActivity(current -> assertTrue(current.gameViewForAutomation() + .settingsVisibleForAutomation())); + tap(activity, 1_700f, 115f); // Language switch on Settings. + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + assertEquals(GameLanguage.RU, view.languageForAutomation()); + assertTrue(view.visibleLocalizedTextForAutomation().contains("НАСТРОЙКИ")); + assertTrue(view.visibleLocalizedTextForAutomation().contains("Русский")); + }); + + activity.recreate(); + + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + assertEquals(GameLanguage.RU, view.languageForAutomation()); + assertFalse(view.settingsVisibleForAutomation()); + + ControlSettings settings = view.controlSettingsForAutomation(); + assertEquals("Large", settings.controlSizeLabel()); + assertEquals("High", settings.controlOpacityLabel()); + assertFalse(settings.hapticsEnabled()); + + ProgressStore progress = new ProgressStore(current); + assertEquals(expectedUnlocked[0], progress.unlockedCount()); + assertEquals(expectedBestDeaths[0], progress.bestDeaths(2)); + assertEquals(expectedBestTime[0], progress.bestTimeMs(2)); + + view.setLanguageForAutomation(GameLanguage.EN); + view.resetControlSettingsForAutomation(); + }); + } + } + + 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/GameLifecycleTest.java b/app/src/androidTest/java/com/projectsacrifice/game/GameLifecycleTest.java index 7a47f74..5add511 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/GameLifecycleTest.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/GameLifecycleTest.java @@ -2,7 +2,6 @@ package com.projectsacrifice.game; import android.app.Instrumentation; -import androidx.lifecycle.Lifecycle; import androidx.test.core.app.ActivityScenario; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; @@ -21,12 +20,16 @@ public final class GameLifecycleTest { public void returningFromBackgroundKeepsPuzzleVisiblePausedAndClearsHeldInput() { Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); try (ActivityScenario scenario = ActivityScenario.launch(MainActivity.class)) { + final MainActivity[] activity = new MainActivity[1]; final GameView[] view = new GameView[1]; - scenario.onActivity(activity -> view[0] = activity.gameViewForAutomation()); + scenario.onActivity(current -> { + activity[0] = current; + view[0] = current.gameViewForAutomation(); + }); createRetainedCorpse(instrumentation, view[0]); runOnGame(instrumentation, view[0], false, false, true, false); - scenario.moveToState(Lifecycle.State.STARTED); + instrumentation.runOnMainSync(activity[0]::onPause); GameView.AutomationSnapshot backgroundStart = snapshot(instrumentation, view[0]); sleep(700L); GameView.AutomationSnapshot backgroundEnd = snapshot(instrumentation, view[0]); @@ -37,7 +40,7 @@ public final class GameLifecycleTest { assertEquals(backgroundStart.playerX(), backgroundEnd.playerX(), 0.01f); assertEquals(backgroundStart.elapsedMilliseconds(), backgroundEnd.elapsedMilliseconds()); - scenario.moveToState(Lifecycle.State.RESUMED); + instrumentation.runOnMainSync(activity[0]::onResume); GameView.AutomationSnapshot returned = snapshot(instrumentation, view[0]); assertTrue("Returning to an active puzzle must show the pause screen", returned.visiblyPaused()); assertFalse("Held touch input must not survive backgrounding", returned.inputActive()); @@ -49,13 +52,31 @@ public final class GameLifecycleTest { GameView.AutomationSnapshot resumed = snapshot(instrumentation, view[0]); assertFalse("Back from the visible pause screen must resume the puzzle", resumed.visiblyPaused()); runOnGame(instrumentation, view[0], false, false, true, false); - sleep(250L); + GameView.AutomationSnapshot moving = waitForMovement( + instrumentation, view[0], resumed.playerX(), 10_000L); runOnGame(instrumentation, view[0], false, false, false, false); - GameView.AutomationSnapshot moving = snapshot(instrumentation, view[0]); assertTrue("The resumed simulation must accept movement", moving.playerX() > resumed.playerX() + 20f); } } + private static GameView.AutomationSnapshot waitForMovement( + Instrumentation instrumentation, GameView gameView, float startX, long timeoutMillis) { + long deadline = System.currentTimeMillis() + timeoutMillis; + GameView.AutomationSnapshot current = snapshot(instrumentation, gameView); + while (System.currentTimeMillis() < deadline) { + if (current.playerX() > startX + 20f) return current; + sleep(24L); + current = snapshot(instrumentation, gameView); + } + throw new AssertionError( + "The resumed simulation did not move within " + timeoutMillis + + "ms; startX=" + startX + + ", finalX=" + current.playerX() + + ", alive=" + current.playerAlive() + + ", paused=" + current.visiblyPaused() + + ", inputActive=" + current.inputActive()); + } + private static void createRetainedCorpse(Instrumentation instrumentation, GameView gameView) { runOnGame(instrumentation, gameView, true, false, true, false); long deadline = System.currentTimeMillis() + 8_000L; diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java b/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java index 437fffc..2335c23 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java @@ -17,13 +17,17 @@ final class LateLevelScenarios { LevelPlaytestBot.Input.rightJump(), snapshot -> snapshot.playerX() >= 640f) .step("Stand on the frozen block", 2_500L, LevelPlaytestBot.Input.none(), - snapshot -> snapshot.playerX() >= 626f + snapshot -> snapshot.playerOnGround() + && snapshot.playerX() >= 626f && snapshot.playerY() >= 668f && snapshot.playerY() <= 690f) - .step("Jump from the block to the first ledge", 2_500L, + .step("Land on the first ledge from the frozen block", 3_000L, LevelPlaytestBot.Input.rightJump(), - snapshot -> snapshot.playerX() >= 750f - && snapshot.playerY() <= 505f) + LevelPlaytestBot.whileDeathsAtMost(1, + snapshot -> snapshot.playerAlive() + && snapshot.playerOnGround() + && snapshot.playerX() >= 810f + && snapshot.playerY() <= 500f)) .step("Cross the first ledge", 3_000L, LevelPlaytestBot.Input.right(), snapshot -> snapshot.playerX() >= 1_230f) .step("Jump the gap to the second ledge", 2_500L, @@ -117,18 +121,29 @@ final class LateLevelScenarios { LevelPlaytestBot.Input.right(), snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1) + .step("Observe the delivered body power the lift", 3_000L, + LevelPlaytestBot.Input.none(), + snapshot -> snapshot.deaths() == 1 + && snapshot.corpseCount() == 1 + && snapshot.firstMovingPlatformPowered()) .step("Wait for the freezer volunteer", 3_000L, LevelPlaytestBot.Input.none(), snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) .step("Approach the launch saw", 2_500L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerX() >= 350f) + LevelPlaytestBot.whileDeathsAtMost(1, + LevelPlaytestBot.groundedAliveAtOrBeyond(320f))) .step("Take off before the launch saw", 1_000L, LevelPlaytestBot.Input.rightJump(), - snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f) + LevelPlaytestBot.whileDeathsAtMost(1, + snapshot -> snapshot.playerAlive() + && !snapshot.playerOnGround() + && snapshot.playerY() <= 580f)) .step("Clear the launch saw", 2_000L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f) + LevelPlaytestBot.whileDeathsAtMost(1, + snapshot -> snapshot.playerAlive() + && snapshot.playerX() >= 680f)) .step("Approach the delivered lift body", 1_500L, LevelPlaytestBot.Input.right(), snapshot -> snapshot.playerX() >= 730f) @@ -143,13 +158,19 @@ final class LateLevelScenarios { snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) .step("Return to the launch saw", 2_500L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerX() >= 350f) + LevelPlaytestBot.whileDeathsAtMost(2, + LevelPlaytestBot.groundedAliveAtOrBeyond(320f))) .step("Take off before the saw again", 1_000L, LevelPlaytestBot.Input.rightJump(), - snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f) + LevelPlaytestBot.whileDeathsAtMost(2, + snapshot -> snapshot.playerAlive() + && !snapshot.playerOnGround() + && snapshot.playerY() <= 580f)) .step("Clear the saw again", 2_000L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f) + LevelPlaytestBot.whileDeathsAtMost(2, + snapshot -> snapshot.playerAlive() + && snapshot.playerX() >= 680f)) .step("Approach the lift body again", 1_500L, LevelPlaytestBot.Input.right(), snapshot -> snapshot.playerX() >= 730f) @@ -190,13 +211,19 @@ final class LateLevelScenarios { snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) .step("Approach the saw for the final crossing", 2_500L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerX() >= 350f) + LevelPlaytestBot.whileDeathsAtMost(3, + LevelPlaytestBot.groundedAliveAtOrBeyond(320f))) .step("Take off for the final saw crossing", 1_000L, LevelPlaytestBot.Input.rightJump(), - snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f) + LevelPlaytestBot.whileDeathsAtMost(3, + snapshot -> snapshot.playerAlive() + && !snapshot.playerOnGround() + && snapshot.playerY() <= 580f)) .step("Clear the saw for the final crossing", 2_000L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f) + LevelPlaytestBot.whileDeathsAtMost(3, + snapshot -> snapshot.playerAlive() + && snapshot.playerX() >= 680f)) .step("Approach the retained lift body", 1_500L, LevelPlaytestBot.Input.right(), snapshot -> snapshot.playerX() >= 730f) @@ -251,7 +278,8 @@ final class LateLevelScenarios { && (snapshot.playerOnMovingPlatform() || (snapshot.playerY() >= 770f && !Float.isNaN(snapshot.firstMovingPlatformY()) - && snapshot.firstMovingPlatformY() >= 780f))) + && snapshot.firstMovingPlatformY() >= 750f + && snapshot.firstMovingPlatformMovingDown()))) .step("Take off toward the moving lift", 1_000L, LevelPlaytestBot.Input.rightJump(), snapshot -> snapshot.playerAlive() @@ -259,7 +287,8 @@ final class LateLevelScenarios { || snapshot.playerY() <= 670f)) .step("Board the moving lift", 2_000L, LevelPlaytestBot.Input.right(), - GameView.AutomationSnapshot::playerOnMovingPlatform) + LevelPlaytestBot.whileDeathsAtMost(3, + GameView.AutomationSnapshot::playerOnMovingPlatform)) .step("Brake toward the lift center", 1_000L, LevelPlaytestBot.Input.left(), snapshot -> snapshot.playerOnMovingPlatform() diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java b/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java index 905c8a7..3666368 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java @@ -3,6 +3,9 @@ package com.projectsacrifice.game; /** Black-box level-8 scenarios driven only through the production input boundary. */ final class LevelEightScenario { private static final long DECISION_MILLIS = 32L; + private static final float LAUNCH_PLATFORM_X = 1_150f; + + private String lastStep = "Start level 8"; static final class Result { private final boolean completed; @@ -21,145 +24,215 @@ final class LevelEightScenario { } Result recoverAfterClockExpires(LevelPlaytestBot.Driver driver, long timeoutMillis) { - long deadline = driver.currentTimeMillis() + timeoutMillis; driver.startLevel(8); - GameView.AutomationSnapshot snapshot = crossPit(driver, deadline); - if (failed(snapshot, deadline, driver)) { - return failure("Cross the pit before the clock expires", snapshot); + GameView.AutomationSnapshot snapshot = driver.snapshot(); + PlaytestSimulationBudget budget = PlaytestSimulationBudget.start( + snapshot.elapsedMilliseconds(), driver.currentTimeMillis(), timeoutMillis); + snapshot = crossPit(driver, budget); + if (failed(snapshot, budget, driver)) { + return failure("Cross the pit before the clock expires", snapshot, budget, driver); } - snapshot = await(driver, deadline, "Wait for the far-side trap", + snapshot = await(driver, budget, "Move clear of the platform sweep", + LevelPlaytestBot.Input.right(), + current -> current.playerX() >= 1_680f + && current.playerY() >= 779f && current.allDoorsOpen()); + if (failed(snapshot, budget, driver)) { + return failure("Move clear of the platform sweep", snapshot, budget, driver); + } + + snapshot = await(driver, budget, "Wait for the far-side trap", LevelPlaytestBot.Input.none(), current -> current.playerX() >= 1_510f && !current.allDoorsOpen()); - if (failed(snapshot, deadline, driver)) { - return failure("Wait for the far-side trap", snapshot); + if (failed(snapshot, budget, driver)) { + return failure("Wait for the far-side trap", snapshot, budget, driver); } - snapshot = await(driver, deadline, "Return beneath the recovery relay", - LevelPlaytestBot.Input.left(), - current -> current.playerX() <= 1_680f && current.playerY() >= 779f); - if (failed(snapshot, deadline, driver)) { - return failure("Return beneath the recovery relay", snapshot); + snapshot = positionBeneathRecoveryRelay(driver, budget); + if (failed(snapshot, budget, driver)) { + return failure("Return beneath the recovery relay", snapshot, budget, driver); } - snapshot = pulseLeftJumpThenAwait(driver, deadline, "Jump into the recovery relay", + snapshot = pulseRightJumpThenAwait(driver, budget, "Jump into the recovery relay", current -> current.allDoorsOpen()); - if (failed(snapshot, deadline, driver)) { - return failure("Jump into the recovery relay", snapshot); + if (failed(snapshot, budget, driver)) { + return failure("Jump into the recovery relay", snapshot, budget, driver); } - snapshot = await(driver, deadline, "Land after rearming the clock", - LevelPlaytestBot.Input.none(), current -> current.playerY() >= 779f); - if (failed(snapshot, deadline, driver)) { - return failure("Land after rearming the clock", snapshot); + snapshot = await(driver, budget, "Land after rearming the clock", + LevelPlaytestBot.Input.right(), + current -> current.playerX() >= 1_650f + && current.playerY() >= 779f && current.allDoorsOpen()); + if (failed(snapshot, budget, driver)) { + return failure("Land after rearming the clock", snapshot, budget, driver); } - return finishFromFarSide(driver, deadline, snapshot, "Finish after recovery"); + return finishFromFarSide(driver, budget, snapshot, "Finish after recovery"); } Result completeIntendedRoute(LevelPlaytestBot.Driver driver, long timeoutMillis) { - long deadline = driver.currentTimeMillis() + timeoutMillis; driver.startLevel(8); - GameView.AutomationSnapshot snapshot = crossPit(driver, deadline); - if (failed(snapshot, deadline, driver)) { - return failure("Cross the pit before the clock expires", snapshot); + GameView.AutomationSnapshot snapshot = driver.snapshot(); + PlaytestSimulationBudget budget = PlaytestSimulationBudget.start( + snapshot.elapsedMilliseconds(), driver.currentTimeMillis(), timeoutMillis); + snapshot = crossPit(driver, budget); + if (failed(snapshot, budget, driver)) { + return failure("Cross the pit before the clock expires", snapshot, budget, driver); } - return finishFromFarSide(driver, deadline, snapshot, "Finish on the original timer"); + return finishFromFarSide(driver, budget, snapshot, "Finish on the original timer"); } - private GameView.AutomationSnapshot crossPit(LevelPlaytestBot.Driver driver, long deadline) { - GameView.AutomationSnapshot snapshot = await(driver, deadline, "Trigger the left timer", + private GameView.AutomationSnapshot crossPit(LevelPlaytestBot.Driver driver, + PlaytestSimulationBudget budget) { + GameView.AutomationSnapshot snapshot = await(driver, budget, "Trigger the left timer", LevelPlaytestBot.Input.right(), GameView.AutomationSnapshot::allDoorsOpen); - if (failed(snapshot, deadline, driver)) return snapshot; + if (failed(snapshot, budget, driver)) return snapshot; long timerStartedAt = snapshot.elapsedMilliseconds(); - snapshot = await(driver, deadline, "Approach the pit", + snapshot = await(driver, budget, "Approach the pit", LevelPlaytestBot.Input.right(), current -> current.playerX() >= 760f); - if (failed(snapshot, deadline, driver)) return snapshot; + if (failed(snapshot, budget, driver)) return snapshot; - snapshot = await(driver, deadline, "Wait for the moving platform", + snapshot = await(driver, budget, "Wait for the outbound moving platform", LevelPlaytestBot.Input.none(), current -> current.elapsedMilliseconds() >= timerStartedAt + 2_250L - && current.firstMovingPlatformX() <= 1_050f); - if (failed(snapshot, deadline, driver)) return snapshot; + && MovingPlatformLaunchPolicy.readyToBoard(current, 1_050f)); + if (failed(snapshot, budget, driver)) return snapshot; driver.setInput(false, true, true); driver.waitForNextDecision(64L); driver.setInput(false, true, false); - snapshot = await(driver, deadline, "Land on the moving platform", + snapshot = await(driver, budget, "Land on the moving platform", LevelPlaytestBot.Input.right(), GameView.AutomationSnapshot::playerOnMovingPlatform); - if (failed(snapshot, deadline, driver)) return snapshot; + if (failed(snapshot, budget, driver)) return snapshot; + + snapshot = await(driver, budget, "Ride the moving platform to the launch zone", + LevelPlaytestBot.Input.none(), + current -> MovingPlatformLaunchPolicy.ready(current, LAUNCH_PLATFORM_X)); + if (failed(snapshot, budget, driver)) return snapshot; driver.setInput(false, true, true); driver.waitForNextDecision(64L); driver.setInput(false, true, false); - snapshot = await(driver, deadline, "Jump from the moving platform to the far floor", + snapshot = await(driver, budget, "Jump from the moving platform to the far floor", LevelPlaytestBot.Input.right(), current -> current.playerX() >= 1_510f); - if (failed(snapshot, deadline, driver)) return snapshot; - snapshot = awaitFarFloorLandingOnOriginalTimer(driver, deadline); - if (failed(snapshot, deadline, driver)) return snapshot; - return await(driver, deadline, "Move under the relay after landing", + if (failed(snapshot, budget, driver)) return snapshot; + snapshot = awaitFarFloorLandingOnOriginalTimer(driver, budget); + if (failed(snapshot, budget, driver)) return snapshot; + return await(driver, budget, "Move under the relay after landing", LevelPlaytestBot.Input.right(), current -> current.playerX() >= 1_600f && current.playerY() >= 779f && current.allDoorsOpen()); } private GameView.AutomationSnapshot awaitFarFloorLandingOnOriginalTimer( - LevelPlaytestBot.Driver driver, long deadline + LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget ) { - GameView.AutomationSnapshot snapshot = await(driver, deadline, + GameView.AutomationSnapshot snapshot = await(driver, budget, "Land on the far floor", LevelPlaytestBot.Input.right(), current -> current.playerX() >= 1_510f && current.playerY() >= 779f && current.allDoorsOpen()); - if (failed(snapshot, deadline, driver)) return snapshot; - return await(driver, deadline, "Stabilize on the far floor", + if (failed(snapshot, budget, driver)) return snapshot; + return await(driver, budget, "Stabilize on the far floor", LevelPlaytestBot.Input.left(), current -> current.playerX() < 1_580f && current.playerY() >= 779f && current.allDoorsOpen()); } - private Result finishFromFarSide(LevelPlaytestBot.Driver driver, long deadline, + private Result finishFromFarSide(LevelPlaytestBot.Driver driver, + PlaytestSimulationBudget budget, GameView.AutomationSnapshot snapshot, String step) { - snapshot = await(driver, deadline, "Reach the open-door jump", - LevelPlaytestBot.Input.right(), - current -> current.playerX() >= 1_730f && current.allDoorsOpen()); - if (failed(snapshot, deadline, driver)) { - return failure("Reach the open-door jump", snapshot); + snapshot = positionForFinalJump(driver, budget); + if (failed(snapshot, budget, driver) || !snapshot.allDoorsOpen()) { + return failure("Reach the open-door jump", snapshot, budget, driver); } driver.setInput(false, true, true); driver.waitForNextDecision(64L); driver.setInput(false, true, false); - snapshot = await(driver, deadline, step, LevelPlaytestBot.Input.right(), + snapshot = await(driver, budget, step, LevelPlaytestBot.Input.right(), GameView.AutomationSnapshot::completed); driver.setInput(false, false, false); - if (!snapshot.completed()) return failure(step, snapshot); - return new Result(true, snapshot.deaths(), step + " completed; " + state(snapshot)); + if (!snapshot.completed()) return failure(step, snapshot, budget, driver); + return new Result(true, snapshot.deaths(), step + " completed; " + + budget.timingState(snapshot.elapsedMilliseconds(), driver.currentTimeMillis()) + + "; " + state(snapshot)); } - private GameView.AutomationSnapshot pulseLeftJumpThenAwait( - LevelPlaytestBot.Driver driver, long deadline, String step, + private GameView.AutomationSnapshot positionForFinalJump( + LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget + ) { + lastStep = "Reach the open-door jump"; + GameView.AutomationSnapshot snapshot = driver.snapshot(); + while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis()) + && snapshot.playerAlive() && !snapshot.completed() && snapshot.allDoorsOpen()) { + if (snapshot.playerOnGround() + && snapshot.playerX() >= 1_690f && snapshot.playerX() <= 1_730f) { + driver.setInput(false, false, false); + return snapshot; + } + if (snapshot.playerX() > 1_730f) { + driver.setInput(true, false, false); + } else { + driver.setInput(false, true, false); + } + driver.waitForNextDecision(DECISION_MILLIS); + snapshot = driver.snapshot(); + } + driver.setInput(false, false, false); + return snapshot; + } + + private GameView.AutomationSnapshot pulseRightJumpThenAwait( + LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget, String step, LevelPlaytestBot.SnapshotCondition completion ) { - driver.setInput(true, false, true); + driver.setInput(false, true, true); driver.waitForNextDecision(64L); - driver.setInput(true, false, false); - return await(driver, deadline, step, LevelPlaytestBot.Input.left(), completion); + driver.setInput(false, true, false); + return await(driver, budget, step, LevelPlaytestBot.Input.right(), completion); + } + + private GameView.AutomationSnapshot positionBeneathRecoveryRelay( + LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget + ) { + lastStep = "Return beneath the recovery relay"; + GameView.AutomationSnapshot snapshot = driver.snapshot(); + while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis()) + && snapshot.playerAlive() && !snapshot.completed()) { + if (snapshot.playerOnGround() + && snapshot.playerX() >= 1_620f && snapshot.playerX() <= 1_650f) { + driver.setInput(false, false, false); + return snapshot; + } + if (snapshot.playerX() > 1_650f) { + driver.setInput(true, false, false); + } else { + driver.setInput(false, true, false); + } + driver.waitForNextDecision(DECISION_MILLIS); + snapshot = driver.snapshot(); + } + driver.setInput(false, false, false); + return snapshot; } private GameView.AutomationSnapshot await( - LevelPlaytestBot.Driver driver, long deadline, String step, + LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget, String step, LevelPlaytestBot.Input input, LevelPlaytestBot.SnapshotCondition completion ) { + lastStep = step; GameView.AutomationSnapshot snapshot = driver.snapshot(); - while (driver.currentTimeMillis() < deadline && snapshot.playerAlive() + while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis()) + && snapshot.playerAlive() && !snapshot.completed() && !completion.reached(snapshot)) { setInput(driver, input); driver.waitForNextDecision(DECISION_MILLIS); snapshot = driver.snapshot(); } - if (driver.currentTimeMillis() >= deadline || !snapshot.playerAlive()) { + if (budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis()) + || !snapshot.playerAlive()) { driver.setInput(false, false, false); } return snapshot; @@ -177,14 +250,20 @@ final class LevelEightScenario { } } - private static boolean failed(GameView.AutomationSnapshot snapshot, long deadline, + private static boolean failed(GameView.AutomationSnapshot snapshot, + PlaytestSimulationBudget budget, LevelPlaytestBot.Driver driver) { return !snapshot.completed() - && (!snapshot.playerAlive() || driver.currentTimeMillis() >= deadline); + && (!snapshot.playerAlive() + || budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())); } - private static Result failure(String step, GameView.AutomationSnapshot snapshot) { - return new Result(false, snapshot.deaths(), step + " failed; " + state(snapshot)); + private Result failure(String step, GameView.AutomationSnapshot snapshot, + PlaytestSimulationBudget budget, LevelPlaytestBot.Driver driver) { + return new Result(false, snapshot.deaths(), step + " failed; lastStep=" + lastStep + + "; " + budget.timingState( + snapshot.elapsedMilliseconds(), driver.currentTimeMillis()) + + "; " + state(snapshot)); } private static String state(GameView.AutomationSnapshot snapshot) { @@ -194,6 +273,12 @@ final class LevelEightScenario { + ", doorOpen=" + snapshot.allDoorsOpen() + ", completed=" + snapshot.completed() + ", elapsedMs=" + snapshot.elapsedMilliseconds() - + ", deaths=" + snapshot.deaths(); + + ", deaths=" + snapshot.deaths() + + ", platformX=" + snapshot.firstMovingPlatformX() + + ", platformY=" + snapshot.firstMovingPlatformY() + + ", onMovingPlatform=" + snapshot.playerOnMovingPlatform() + + ", onGround=" + snapshot.playerOnGround() + + ", platformPowered=" + snapshot.firstMovingPlatformPowered() + + ", platformMovingRight=" + snapshot.firstMovingPlatformMovingRight(); } } diff --git a/app/src/androidTest/java/com/projectsacrifice/game/OnboardingAndResultFlowTest.java b/app/src/androidTest/java/com/projectsacrifice/game/OnboardingAndResultFlowTest.java new file mode 100644 index 0000000..3ab7a67 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/OnboardingAndResultFlowTest.java @@ -0,0 +1,86 @@ +package com.projectsacrifice.game; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +public final class OnboardingAndResultFlowTest { + @Test + public void tutorialIsOptionalNonBlockingAndDoesNotRepeatAfterRecreation() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + view.resetOnboardingForAutomation(); + view.startLevelForAutomation(1); + assertTrue(view.onboardingVisibleForAutomation()); + assertEquals(OnboardingFlow.Step.MOVEMENT, + view.onboardingStepForAutomation()); + + view.setAutomationInput(false, true, false); + assertTrue("Tutorial must not disable gameplay controls", + view.automationSnapshot().inputActive()); + + view.advanceOnboardingForAutomation(); + view.advanceOnboardingForAutomation(); + view.advanceOnboardingForAutomation(); + assertFalse(view.onboardingVisibleForAutomation()); + }); + + activity.recreate(); + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + view.startLevelForAutomation(1); + assertFalse(view.onboardingVisibleForAutomation()); + }); + } + } + + @Test + public void completionShowsRunAndSavedBestAndAllResultRoutesPreserveState() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + view.selectNextControlSizeForAutomation(); + String selectedSize = view.controlSettingsForAutomation().controlSizeLabel(); + + view.startLevelForAutomation(1); + view.finishLevelForAutomation(2, 42_000L); + CompletionResult result = view.completionResultForAutomation(); + assertNotNull(result); + assertTrue(view.resultVisibleForAutomation()); + assertEquals(2, result.currentDeaths()); + assertEquals(42_000L, result.currentTimeMs()); + assertTrue(result.bestDeaths() <= result.currentDeaths()); + assertTrue(result.bestTimeMs() <= result.currentTimeMs()); + assertTrue(view.levelCompletedForAutomation(1)); + + view.replayFromResultForAutomation(); + assertEquals(1, view.automationSnapshot().levelNumber()); + assertFalse(view.automationSnapshot().completed()); + assertEquals(selectedSize, view.controlSettingsForAutomation().controlSizeLabel()); + assertTrue(view.levelCompletedForAutomation(1)); + + view.finishLevelForAutomation(1, 35_000L); + view.nextLevelFromResultForAutomation(); + assertEquals(2, view.automationSnapshot().levelNumber()); + assertEquals(selectedSize, view.controlSettingsForAutomation().controlSizeLabel()); + assertTrue(view.levelCompletedForAutomation(1)); + + view.finishLevelForAutomation(1, 35_000L); + view.levelsFromResultForAutomation(); + assertTrue(view.levelsVisibleForAutomation()); + assertEquals(selectedSize, view.controlSettingsForAutomation().controlSizeLabel()); + assertTrue(view.levelCompletedForAutomation(2)); + view.resetControlSettingsForAutomation(); + }); + } + } +} diff --git a/app/src/androidTest/java/com/projectsacrifice/game/OnboardingStoreTest.java b/app/src/androidTest/java/com/projectsacrifice/game/OnboardingStoreTest.java new file mode 100644 index 0000000..9950b1a --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/OnboardingStoreTest.java @@ -0,0 +1,28 @@ +package com.projectsacrifice.game; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +public final class OnboardingStoreTest { + @Test + public void dismissalSurvivesStoreRecreation() { + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + OnboardingStore first = new OnboardingStore(context); + first.resetForAutomation(); + assertFalse(first.isComplete()); + + first.markComplete(); + + assertTrue(new OnboardingStore(context).isComplete()); + } +} diff --git a/app/src/androidTest/java/com/projectsacrifice/game/StageThreeAcceptanceTest.java b/app/src/androidTest/java/com/projectsacrifice/game/StageThreeAcceptanceTest.java new file mode 100644 index 0000000..2852997 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/StageThreeAcceptanceTest.java @@ -0,0 +1,146 @@ +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 StageThreeAcceptanceTest { + @Test + public void localizedAccessibleSettingsSurviveRecreationWithoutChangingProgress() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + int[] unlocked = new int[1]; + int[] bestDeaths = new int[1]; + long[] bestTime = new long[1]; + + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + view.resetLanguageForAutomation(); + view.resetControlSettingsForAutomation(); + view.toggleHapticsForAutomation(); + view.resetAccessibilitySettingsForAutomation(); + ProgressStore progress = new ProgressStore(owner); + unlocked[0] = progress.unlockedCount(); + bestDeaths[0] = progress.bestDeaths(1); + bestTime[0] = progress.bestTimeMs(1); + }); + + tap(activity, 1_700f, 115f); // Settings. + tap(activity, 1_700f, 115f); // RU. + tap(activity, 220f, 345f); // Large text. + tap(activity, 220f, 515f); // High contrast. + tap(activity, 1_250f, 345f); // Large controls. + tap(activity, 1_250f, 515f); // High control opacity. + + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + assertEquals(GameLanguage.RU, view.languageForAutomation()); + assertEquals(1.25f, view.textScaleForAutomation(), 0f); + assertTrue(view.highContrastForAutomation()); + assertTrue(view.visibleLocalizedTextForAutomation().contains("ДОСТУПНОСТЬ")); + assertTrue(view.visibleLocalizedTextForAutomation().contains("Крупный")); + assertTrue(view.screenDescriptionForAutomation().contains("Настройки")); + }); + + activity.recreate(); + + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + assertEquals(GameLanguage.RU, view.languageForAutomation()); + assertEquals(1.25f, view.textScaleForAutomation(), 0f); + assertTrue(view.highContrastForAutomation()); + assertEquals("Large", view.controlSettingsForAutomation().controlSizeLabel()); + assertEquals("High", view.controlSettingsForAutomation().controlOpacityLabel()); + assertFalse(view.controlSettingsForAutomation().hapticsEnabled()); + + ProgressStore progress = new ProgressStore(owner); + assertEquals(unlocked[0], progress.unlockedCount()); + assertEquals(bestDeaths[0], progress.bestDeaths(1)); + assertEquals(bestTime[0], progress.bestTimeMs(1)); + + view.resetLanguageForAutomation(); + view.resetControlSettingsForAutomation(); + view.resetAccessibilitySettingsForAutomation(); + }); + } + } + + @Test + public void russianTutorialAndResultRemainNonBlockingAndExposeEveryOutcome() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + view.setLanguageForAutomation(GameLanguage.RU); + view.resetOnboardingForAutomation(); + view.startLevelForAutomation(1); + + assertTrue(view.onboardingVisibleForAutomation()); + assertContainsPrefix(view.visibleLocalizedTextForAutomation(), "ДВИЖЕНИЕ"); + view.setAutomationInput(false, true, false); + assertTrue(view.automationSnapshot().inputActive()); + + view.advanceOnboardingForAutomation(); + assertContainsPrefix(view.visibleLocalizedTextForAutomation(), + "ДВИЖЕНИЕ + ПРЫЖОК"); + view.advanceOnboardingForAutomation(); + assertContainsPrefix(view.visibleLocalizedTextForAutomation(), + "СМЕРТЬ ОСТАВЛЯЕТ ИНСТРУМЕНТ"); + view.advanceOnboardingForAutomation(); + assertFalse(view.onboardingVisibleForAutomation()); + + view.finishLevelForAutomation(2, 42_000L); + List result = view.visibleLocalizedTextForAutomation(); + assertContainsPrefix(result, "Текущий результат"); + assertContainsPrefix(result, "Лучший результат"); + assertContainsPrefix(result, "Цели"); + assertContainsPrefix(result, "Следующая цель"); + assertTrue(view.screenDescriptionForAutomation().contains("Уровень завершён")); + view.setLanguageForAutomation(GameLanguage.EN); + }); + + activity.recreate(); + + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + view.startLevelForAutomation(1); + assertFalse(view.onboardingVisibleForAutomation()); + assertEquals(GameLanguage.EN, view.languageForAutomation()); + view.setAutomationInput(false, false, false); + }); + } + } + + private static void assertContainsPrefix(List values, String prefix) { + for (String value : values) { + if (value.startsWith(prefix)) return; + } + throw new AssertionError("Missing prefix '" + prefix + "' in " + values); + } + + private static void tap(ActivityScenario activity, float canvasX, float canvasY) { + activity.onActivity(owner -> { + GameView view = owner.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/main/java/com/projectsacrifice/game/AccessibilityScreenDescription.java b/app/src/main/java/com/projectsacrifice/game/AccessibilityScreenDescription.java new file mode 100644 index 0000000..d5cea9e --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/AccessibilityScreenDescription.java @@ -0,0 +1,59 @@ +package com.projectsacrifice.game; + +final class AccessibilityScreenDescription { + private AccessibilityScreenDescription() { + } + + static String levelSelect(int unlocked, int total) { + return "Level select. " + unlocked + " of " + total + + " unlocked. Choose an unlocked level or open Settings."; + } + + static String settings(ControlSettings controls, AccessibilitySettings accessibility) { + return "Settings. Control size " + controls.controlSizeLabel() + + ". Control opacity " + controls.controlOpacityLabel() + + ". Vibration " + controls.hapticsLabel() + + ". Text size " + accessibility.textSizeLabel() + + ". High contrast " + accessibility.highContrastLabel() + + ". Back."; + } + + static String playing(LevelDefinition level, int deaths, String elapsed, + String criticalState, boolean paused) { + if (paused) { + return "Paused on level " + level.number + ", " + level.title + + ". Resume or return to level select."; + } + return "Playing level " + level.number + ", " + level.title + + ". " + deaths + " volunteers. Time " + elapsed + + ". " + criticalState + + ". Move left, move right, jump, restart, pause."; + } + + static String complete(LevelDefinition level, int deaths, String elapsed, + boolean hasNextLevel) { + return "Level complete. " + level.title + ". " + deaths + + " volunteers. Time " + elapsed + + ". Level select. Replay." + + (hasNextLevel ? " Next level." : ""); + } + + static String levelSelect(GameStrings strings, int unlocked, int total) { + return strings.levelSelectDescription(unlocked, total); + } + + static String settings(GameStrings strings, ControlSettings controls, + AccessibilitySettings accessibility) { + return strings.settingsDescription(controls, accessibility); + } + + static String playing(GameStrings strings, LevelDefinition level, int deaths, + String elapsed, String criticalState, boolean paused) { + return strings.playingDescription(level.number, deaths, elapsed, criticalState, paused); + } + + static String complete(GameStrings strings, LevelDefinition level, int deaths, + String elapsed, boolean hasNextLevel) { + return strings.completeDescription(level.number, deaths, elapsed, hasNextLevel); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/AccessibilitySettings.java b/app/src/main/java/com/projectsacrifice/game/AccessibilitySettings.java new file mode 100644 index 0000000..efe9d1f --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/AccessibilitySettings.java @@ -0,0 +1,69 @@ +package com.projectsacrifice.game; + +public final class AccessibilitySettings { + private enum TextSize { + STANDARD("Standard", 1f), + LARGE("Large", 1.25f); + + final String label; + final float scale; + + TextSize(String label, float scale) { + this.label = label; + this.scale = scale; + } + } + + private final TextSize textSize; + private final boolean highContrastEnabled; + + private AccessibilitySettings(TextSize textSize, boolean highContrastEnabled) { + this.textSize = textSize; + this.highContrastEnabled = highContrastEnabled; + } + + public static AccessibilitySettings defaults() { + return new AccessibilitySettings(TextSize.STANDARD, false); + } + + static AccessibilitySettings restore(int textSizeIndex, boolean highContrastEnabled) { + TextSize[] values = TextSize.values(); + TextSize restored = textSizeIndex >= 0 && textSizeIndex < values.length + ? values[textSizeIndex] : TextSize.STANDARD; + return new AccessibilitySettings(restored, highContrastEnabled); + } + + public String textSizeLabel() { + return textSize.label; + } + + public float textScale() { + return textSize.scale; + } + + public float canvasTextSize(float baseSize) { + return baseSize * textSize.scale; + } + + public AccessibilitySettings nextTextSize() { + TextSize[] values = TextSize.values(); + return new AccessibilitySettings(values[(textSize.ordinal() + 1) % values.length], + highContrastEnabled); + } + + public boolean highContrastEnabled() { + return highContrastEnabled; + } + + public String highContrastLabel() { + return highContrastEnabled ? "On" : "Off"; + } + + public AccessibilitySettings toggleHighContrast() { + return new AccessibilitySettings(textSize, !highContrastEnabled); + } + + int textSizeIndex() { + return textSize.ordinal(); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/AccessibilitySettingsStore.java b/app/src/main/java/com/projectsacrifice/game/AccessibilitySettingsStore.java new file mode 100644 index 0000000..9c10436 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/AccessibilitySettingsStore.java @@ -0,0 +1,28 @@ +package com.projectsacrifice.game; + +import android.content.Context; +import android.content.SharedPreferences; + +final class AccessibilitySettingsStore { + private static final String FILE = "project_sacrifice_accessibility_settings_v1"; + private final SharedPreferences preferences; + + AccessibilitySettingsStore(Context context) { + preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE); + } + + AccessibilitySettings load() { + AccessibilitySettings defaults = AccessibilitySettings.defaults(); + return AccessibilitySettings.restore( + preferences.getInt("text_size", defaults.textSizeIndex()), + preferences.getBoolean("high_contrast", defaults.highContrastEnabled()) + ); + } + + void save(AccessibilitySettings settings) { + preferences.edit() + .putInt("text_size", settings.textSizeIndex()) + .putBoolean("high_contrast", settings.highContrastEnabled()) + .apply(); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/AccessibilityStateCache.java b/app/src/main/java/com/projectsacrifice/game/AccessibilityStateCache.java new file mode 100644 index 0000000..b58d20e --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/AccessibilityStateCache.java @@ -0,0 +1,58 @@ +package com.projectsacrifice.game; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Allocation-free critical-state comparison after a level has been configured. */ +final class AccessibilityStateCache { + private List signalIds = Collections.emptyList(); + private GameLanguage language; + private int activeSignals = Integer.MIN_VALUE; + private int openDoors = Integer.MIN_VALUE; + private int totalDoors = Integer.MIN_VALUE; + private boolean playerAlive; + private String statusText; + + void configureSignals(List sourceIds) { + ArrayList unique = new ArrayList<>(); + for (String signalId : sourceIds) { + if (!unique.contains(signalId)) unique.add(signalId); + } + signalIds = Collections.unmodifiableList(unique); + language = null; + activeSignals = Integer.MIN_VALUE; + openDoors = Integer.MIN_VALUE; + totalDoors = Integer.MIN_VALUE; + statusText = null; + } + + List signalIds() { + return signalIds; + } + + boolean update(GameStrings strings, int updatedActiveSignals, int updatedOpenDoors, + int updatedTotalDoors, boolean updatedPlayerAlive) { + GameLanguage updatedLanguage = strings.language(); + if (statusText != null + && language == updatedLanguage + && activeSignals == updatedActiveSignals + && openDoors == updatedOpenDoors + && totalDoors == updatedTotalDoors + && playerAlive == updatedPlayerAlive) { + return false; + } + language = updatedLanguage; + activeSignals = updatedActiveSignals; + openDoors = updatedOpenDoors; + totalDoors = updatedTotalDoors; + playerAlive = updatedPlayerAlive; + statusText = AccessibilityStatus.describe(strings, activeSignals, signalIds.size(), + openDoors, totalDoors, playerAlive); + return true; + } + + String statusText() { + return statusText; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/AccessibilityStatus.java b/app/src/main/java/com/projectsacrifice/game/AccessibilityStatus.java new file mode 100644 index 0000000..1b5fcfb --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/AccessibilityStatus.java @@ -0,0 +1,19 @@ +package com.projectsacrifice.game; + +final class AccessibilityStatus { + private AccessibilityStatus() { + } + + static String describe(int activeSignals, int totalSignals, int openDoors, int totalDoors, + boolean playerAlive) { + return "Signals " + activeSignals + "/" + totalSignals + + " · Doors " + openDoors + "/" + totalDoors + + " · Volunteer " + (playerAlive ? "active" : "respawning"); + } + + static String describe(GameStrings strings, int activeSignals, int totalSignals, + int openDoors, int totalDoors, boolean playerAlive) { + return strings.accessibilityStatus(activeSignals, totalSignals, openDoors, totalDoors, + playerAlive); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/CompletionResult.java b/app/src/main/java/com/projectsacrifice/game/CompletionResult.java new file mode 100644 index 0000000..0fe4063 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/CompletionResult.java @@ -0,0 +1,67 @@ +package com.projectsacrifice.game; + +/** Immutable completion statistics used by rendering and device-level navigation tests. */ +public final class CompletionResult { + public enum NextGoal { + TARGET_DEATHS, + TARGET_TIME, + NEXT_LEVEL, + PERSONAL_BEST + } + + private final int currentDeaths; + private final long currentTimeMs; + private final int bestDeaths; + private final long bestTimeMs; + private final int targetDeaths; + private final long targetTimeMs; + private final NextGoal nextGoal; + private final long nextGoalValue; + + private CompletionResult(int currentDeaths, long currentTimeMs, + int bestDeaths, long bestTimeMs, + int targetDeaths, long targetTimeMs, + NextGoal nextGoal, long nextGoalValue) { + this.currentDeaths = currentDeaths; + this.currentTimeMs = currentTimeMs; + this.bestDeaths = bestDeaths; + this.bestTimeMs = bestTimeMs; + this.targetDeaths = targetDeaths; + this.targetTimeMs = targetTimeMs; + this.nextGoal = nextGoal; + this.nextGoalValue = nextGoalValue; + } + + public static CompletionResult create(LevelDefinition level, + int currentDeaths, long currentTimeMs, + int bestDeaths, long bestTimeMs, + boolean hasNextLevel) { + long targetTimeMs = level.targetSeconds * 1_000L; + NextGoal goal; + long goalValue; + if (currentDeaths > level.targetDeaths) { + goal = NextGoal.TARGET_DEATHS; + goalValue = level.targetDeaths; + } else if (currentTimeMs > targetTimeMs) { + goal = NextGoal.TARGET_TIME; + goalValue = targetTimeMs; + } else if (hasNextLevel) { + goal = NextGoal.NEXT_LEVEL; + goalValue = level.number + 1L; + } else { + goal = NextGoal.PERSONAL_BEST; + goalValue = 0L; + } + return new CompletionResult(currentDeaths, currentTimeMs, bestDeaths, bestTimeMs, + level.targetDeaths, targetTimeMs, goal, goalValue); + } + + public int currentDeaths() { return currentDeaths; } + public long currentTimeMs() { return currentTimeMs; } + public int bestDeaths() { return bestDeaths; } + public long bestTimeMs() { return bestTimeMs; } + public int targetDeaths() { return targetDeaths; } + public long targetTimeMs() { return targetTimeMs; } + public NextGoal nextGoal() { return nextGoal; } + public long nextGoalValue() { return nextGoalValue; } +} diff --git a/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java b/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java index a3359ce..0432212 100644 --- a/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java +++ b/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java @@ -45,7 +45,9 @@ public final class EarlyLevelScenarios { EarlyLevelScenarios::respawnedAtStart) .step("Approach the completed bridge", 3_000L, LevelPlaytestBot.Input.right(), snapshot -> snapshot.playerX() >= 470f) - .step("Cross the corpse bridge", 3_000L, LevelPlaytestBot.Input.rightJump(), + .step("Land on the far bridge body", 3_000L, LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerOnGround() && snapshot.playerX() >= 800f) + .step("Jump from the bridge to safe floor", 3_000L, LevelPlaytestBot.Input.rightJump(), snapshot -> snapshot.playerX() >= 900f) .step("Release jump before the final ledge", 2_000L, LevelPlaytestBot.Input.right(), snapshot -> snapshot.playerX() >= 1_030f) @@ -65,9 +67,16 @@ public final class EarlyLevelScenarios { .step("Wait for the next volunteer", 2_000L, LevelPlaytestBot.Input.none(), EarlyLevelScenarios::respawnedAtStart) .step("Approach the saw", 3_000L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerX() >= 390f) - .step("Jump over the saw and plate body", 3_000L, LevelPlaytestBot.Input.rightJump(), - snapshot -> snapshot.playerX() >= 700f) + LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive() + && snapshot.playerOnGround() + && snapshot.playerX() >= 360f)) + .step("Take off before the saw", 1_000L, LevelPlaytestBot.Input.rightJump(), + LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive() + && !snapshot.playerOnGround() + && snapshot.playerY() <= 620f)) + .step("Clear the saw and plate body", 3_000L, LevelPlaytestBot.Input.right(), + LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> + snapshot.playerAlive() && snapshot.playerX() >= 700f)) .step("Reach the relic through the open door", 5_000L, LevelPlaytestBot.Input.right(), GameView.AutomationSnapshot::completed) .build(); @@ -76,7 +85,10 @@ public final class EarlyLevelScenarios { public static LevelPlaytestBot.Scenario levelFour() { return LevelPlaytestBot.Scenario.builder(4, "Level 4: toggle lift to gallery") .step("Cross the toggle once and activate the lift", 3_000L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerX() >= 800f) + GameView.AutomationSnapshot::firstMovingPlatformPowered) + .step("Approach the powered moving lift", 3_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.firstMovingPlatformPowered() + && snapshot.playerX() >= 800f) .step("Jump onto the moving lift", 3_000L, LevelPlaytestBot.Input.rightJump(), snapshot -> snapshot.playerX() >= 1_050f && snapshot.playerY() < 690f) .step("Ride the lift to its upper phase", 7_000L, LevelPlaytestBot.Input.none(), diff --git a/app/src/main/java/com/projectsacrifice/game/GameLanguage.java b/app/src/main/java/com/projectsacrifice/game/GameLanguage.java new file mode 100644 index 0000000..34188df --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/GameLanguage.java @@ -0,0 +1,26 @@ +package com.projectsacrifice.game; + +/** Player-selected language, independent from the device locale. */ +public enum GameLanguage { + EN("en"), + RU("ru"); + + private final String code; + + GameLanguage(String code) { + this.code = code; + } + + public String code() { + return code; + } + + public GameLanguage next() { + return this == EN ? RU : EN; + } + + public static GameLanguage fromCode(String code) { + if (RU.code.equalsIgnoreCase(code)) return RU; + return EN; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameLanguageStore.java b/app/src/main/java/com/projectsacrifice/game/GameLanguageStore.java new file mode 100644 index 0000000..2585b00 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/GameLanguageStore.java @@ -0,0 +1,24 @@ +package com.projectsacrifice.game; + +import android.content.Context; +import android.content.SharedPreferences; + +/** Persists the explicit in-game language without mutating progress or controls. */ +final class GameLanguageStore { + private static final String FILE = "project_sacrifice_language_v1"; + private static final String KEY_LANGUAGE = "language"; + private final SharedPreferences preferences; + + GameLanguageStore(Context context) { + preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE); + } + + GameLanguage load() { + return GameLanguage.fromCode(preferences.getString(KEY_LANGUAGE, GameLanguage.EN.code())); + } + + void save(GameLanguage language) { + preferences.edit().putString(KEY_LANGUAGE, + language == null ? GameLanguage.EN.code() : language.code()).apply(); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameStrings.java b/app/src/main/java/com/projectsacrifice/game/GameStrings.java new file mode 100644 index 0000000..f380093 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/GameStrings.java @@ -0,0 +1,336 @@ +package com.projectsacrifice.game; + +/** All player-facing copy behind one typed, Android-independent interface. */ +public final class GameStrings { + private static final String[] EN_TITLES = { + "The Volunteer", "A Useful End", "Dead Weight", "The Long Lift", + "Express Delivery", "Cold Comfort", "Live Wire", "Borrowed Time", + "Two Good Reasons", "The Last Procession" + }; + private static final String[] RU_TITLES = { + "Рыцарь-доброволец", "Полезный конец", "Мёртвый груз", "Долгий подъём", + "Срочная доставка", "Холодное утешение", "Живой провод", "Время взаймы", + "Две веские причины", "Последнее шествие" + }; + private static final String[] EN_HINTS = { + "Reach the relic. Arrows move; the winged button jumps.", + "The spikes keep every volunteer. Build a path from what remains.", + "Leave a body on the brass plate so the next volunteer can pass.", + "Touch the lever, then ride the moving platform to the upper gallery.", + "The saw throws to the right. Deliver a volunteer to the distant plate.", + "The frost makes a solid block. Use the frozen volunteer as a step.", + "A fallen volunteer conducts the current and keeps the relay alive.", + "Jump for the far blue relay if the timed gate closes.", + "The final gate needs weight and power at the same time.", + "Launch, freeze, conduct, ascend. Every lesson opens the last relic." + }; + private static final String[] RU_HINTS = { + "Доберитесь до реликвии. Стрелки двигают; кнопка с крылом — прыжок.", + "Шипы сохраняют каждого добровольца. Проложите путь из того, что осталось.", + "Оставьте тело на латунной плите, чтобы следующий доброволец прошёл.", + "Коснитесь рычага, затем поднимитесь на платформе к верхней галерее.", + "Пила бросает вправо. Доставьте добровольца на дальнюю плиту.", + "Мороз создаёт твёрдый блок. Используйте замёрзшего добровольца как ступень.", + "Павший доброволец проводит ток и поддерживает реле.", + "Прыгните к дальнему синему реле, если ворота по таймеру закроются.", + "Последним воротам одновременно нужны вес и питание.", + "Разгонитесь, замёрзните, проведите ток, поднимитесь. Каждый урок открывает последнюю реликвию." + }; + + private final GameLanguage language; + + private GameStrings(GameLanguage language) { + this.language = language == null ? GameLanguage.EN : language; + } + + public static GameStrings forLanguage(GameLanguage language) { + return new GameStrings(language); + } + + public GameLanguage language() { + return language; + } + + private boolean russian() { + return language == GameLanguage.RU; + } + + public String appContentDescription() { + return russian() + ? "Project Sacrifice. Платформенная головоломка из десяти уровней." + : "Project Sacrifice. Ten-level puzzle platform game."; + } + + public String projectTitle() { return "PROJECT SACRIFICE"; } + public String tagline() { + return russian() ? "Каждый конец оставляет путь вперёд" + : "Every ending leaves a way forward"; + } + public String locked() { return russian() ? "ЗАКРЫТО" : "LOCKED"; } + public String tapToEnter() { return russian() ? "Нажмите, чтобы войти" : "Tap to enter"; } + public String levelSelectFooter() { + return russian() + ? "Альбомная ориентация · мультитач · прогресс сохраняется автоматически" + : "Landscape · multitouch · progress saves automatically"; + } + + public String settingsButton() { + return russian() ? "Настройки" : "Settings"; + } + + public String settingsTitle() { return russian() ? "НАСТРОЙКИ" : "SETTINGS"; } + public String settingsInstruction() { + return russian() ? "Нажмите на строку, чтобы изменить. Всё сохраняется автоматически." + : "Tap a row to change it. Changes save automatically."; + } + public String controlSizeSetting() { return russian() ? "Размер управления" : "Control size"; } + public String controlOpacitySetting() { return russian() ? "Прозрачность управления" : "Control opacity"; } + public String vibrationSetting() { return russian() ? "Вибрация" : "Vibration"; } + public String languageSetting() { return russian() ? "Язык" : "Language"; } + public String languageName() { return russian() ? "Русский" : "English"; } + public String controlSizeValue(int index) { + String[] values = russian() + ? new String[]{"Маленький", "Средний", "Большой"} + : new String[]{"Small", "Medium", "Large"}; + return values[validPreset(index)]; + } + public String controlOpacityValue(int index) { + String[] values = russian() + ? new String[]{"Низкая", "Средняя", "Высокая"} + : new String[]{"Low", "Medium", "High"}; + return values[validPreset(index)]; + } + public String hapticsValue(boolean enabled) { + if (russian()) return enabled ? "Вкл." : "Выкл."; + return enabled ? "On" : "Off"; + } + public String accessibilityTitle() { return russian() ? "ДОСТУПНОСТЬ" : "ACCESSIBILITY"; } + public String textSizeSetting() { return russian() ? "Размер текста" : "Text size"; } + public String textSizeValue(float scale) { + return scale > 1f ? (russian() ? "Крупный" : "Large") + : (russian() ? "Обычный" : "Standard"); + } + public String highContrastSetting() { return russian() ? "Контраст" : "High contrast"; } + public String highContrastValue(boolean enabled) { + return hapticsValue(enabled); + } + public String back() { return russian() ? "Назад" : "Back"; } + + public String hudStats(int deaths, long elapsedMilliseconds) { + if (russian()) { + return "Добровольцы " + deaths + " Время " + GameMath.formatTime(elapsedMilliseconds); + } + return "Volunteers " + deaths + " Time " + GameMath.formatTime(elapsedMilliseconds); + } + public String restart() { return russian() ? "Заново" : "Restart"; } + public String respawning() { + return russian() ? "Следующий доброволец уже в пути…" + : "Another volunteer is on the way…"; + } + + public String levelBestStats(int deaths, long elapsedMilliseconds) { + return "✓ " + deaths + " " + volunteerWord(deaths) + " · " + + GameMath.formatTime(elapsedMilliseconds); + } + public String relicClaimed() { return russian() ? "РЕЛИКВИЯ ПОЛУЧЕНА" : "RELIC CLAIMED"; } + public String completion() { return russian() ? "Завершение ★" : "Completion ★"; } + public String completionDeaths(boolean earned, int deaths, int targetDeaths) { + return (earned ? "★" : "☆") + " " + deaths + " / " + targetDeaths + " " + + volunteerWord(deaths); + } + public String completionTime(boolean earned, long elapsedMilliseconds, long targetMilliseconds) { + return (earned ? "★" : "☆") + " " + GameMath.formatTime(elapsedMilliseconds) + + " / " + GameMath.formatTime(targetMilliseconds); + } + public String levelSelect() { return russian() ? "Выбор уровня" : "Level select"; } + public String replay() { return russian() ? "Повторить" : "Replay"; } + public String nextLevel() { return russian() ? "Следующий уровень" : "Next level"; } + public String paused() { return russian() ? "ПАУЗА" : "PAUSED"; } + public String resume() { return russian() ? "Продолжить" : "Resume"; } + + // Result-flow hooks used by the Stage 3 result screen. + public String currentResult() { return russian() ? "Текущий результат" : "Current result"; } + public String bestResult() { return russian() ? "Лучший результат" : "Best result"; } + public String deathsStat() { return russian() ? "Добровольцы" : "Volunteers"; } + public String timeStat() { return russian() ? "Время" : "Time"; } + public String newBest() { return russian() ? "НОВЫЙ РЕКОРД" : "NEW BEST"; } + + public String currentResultLine(int deaths, long timeMs) { + return currentResult() + " · " + deaths + " " + volunteerWord(deaths) + + " · " + GameMath.formatTime(timeMs); + } + + public String bestResultLine(int deaths, long timeMs) { + return bestResult() + " · " + deaths + " " + volunteerWord(deaths) + + " · " + GameMath.formatTime(timeMs); + } + + public String targetsResultLine(int deaths, long timeMs) { + String label = russian() ? "Цели" : "Targets"; + return label + " · " + deaths + " " + volunteerWord(deaths) + + " · " + GameMath.formatTime(timeMs); + } + + public String nextGoalDeaths(long deaths) { + return russian() + ? "Следующая цель · пройти с " + deaths + " добровольцами" + : "Next goal · finish with " + deaths + " volunteers"; + } + + public String nextGoalTime(long timeMs) { + return (russian() ? "Следующая цель · пройти за " : "Next goal · finish in ") + + GameMath.formatTime(timeMs); + } + + public String nextGoalLevel(long level) { + return (russian() ? "Следующая цель · перейти на уровень " + : "Next goal · enter level ") + level; + } + + public String nextGoalPersonalBest() { + return russian() ? "Следующая цель · улучшить личный рекорд" + : "Next goal · improve your personal best"; + } + + // Onboarding hooks keep new tutorial Canvas copy out of GameView conditionals. + public String tutorialWelcomeTitle() { + return russian() ? "Смерть — это инструмент" : "Death is a tool"; + } + public String tutorialWelcomeBody() { + return russian() + ? "Двигайтесь стрелками и прыгайте кнопкой с крылом. Доберитесь до реликвии." + : "Move with the arrows and jump with the winged button. Reach the relic."; + } + public String tutorialSacrificeTitle() { + return russian() ? "Оставьте путь вперёд" : "Leave a way forward"; + } + public String tutorialSacrificeBody() { + return russian() + ? "После смерти тело остаётся в мире: закрывает шипы, держит плиты и становится ступенью." + : "A body stays in the world after death: cover spikes, hold plates, and make a step."; + } + public String tutorialSkip() { return russian() ? "Пропустить" : "Skip"; } + public String tutorialNext() { return russian() ? "Далее" : "Next"; } + public String tutorialStart() { return russian() ? "Начать" : "Start"; } + public String tutorialDone() { return russian() ? "Готово" : "Done"; } + public String tutorialMovementTitle() { return russian() ? "ДВИЖЕНИЕ" : "MOVE"; } + public String tutorialMovementBody() { + return russian() ? "Удерживайте левую или правую стрелку, чтобы двигаться." + : "Hold the left or right arrow to explore."; + } + public String tutorialJumpTitle() { return russian() ? "ДВИЖЕНИЕ + ПРЫЖОК" : "MOVE + JUMP"; } + public String tutorialJumpBody() { + return russian() ? "Используйте два пальца: держите направление и нажмите Прыжок." + : "Use two fingers: hold a direction and tap Jump."; + } + public String tutorialCorpseTitle() { + return russian() ? "СМЕРТЬ ОСТАВЛЯЕТ ИНСТРУМЕНТ" : "DEATH LEAVES A TOOL"; + } + public String tutorialCorpseBody() { + return russian() ? "Павший доброволец остаётся. Используйте тело, чтобы открыть путь." + : "A fallen volunteer remains. Use the body to open a path."; + } + public String tutorialProgress(int step, int total) { + return step + " / " + total; + } + + public String accessibilityStatus(int activeSignals, int totalSignals, + int openDoors, int totalDoors, boolean playerAlive) { + if (russian()) { + return "Сигналы " + activeSignals + "/" + totalSignals + + " · Двери " + openDoors + "/" + totalDoors + + " · Доброволец " + (playerAlive ? "активен" : "возрождается"); + } + return "Signals " + activeSignals + "/" + totalSignals + + " · Doors " + openDoors + "/" + totalDoors + + " · Volunteer " + (playerAlive ? "active" : "respawning"); + } + + public String levelSelectDescription(int unlocked, int total) { + if (russian()) { + return "Выбор уровня. Открыто " + unlocked + " из " + total + + ". Выберите открытый уровень или Настройки."; + } + return "Level select. " + unlocked + " of " + total + + " unlocked. Choose an unlocked level or open Settings."; + } + + public String settingsDescription(ControlSettings controls, + AccessibilitySettings accessibility) { + if (russian()) { + return "Настройки. Размер управления " + + controlSizeValue(controls.controlSizeIndex()) + + ". Прозрачность управления " + + controlOpacityValue(controls.controlOpacityIndex()) + + ". Вибрация " + hapticsValue(controls.hapticsEnabled()) + + ". Размер текста " + textSizeValue(accessibility.textScale()) + + ". Контраст " + highContrastValue(accessibility.highContrastEnabled()) + + ". Назад."; + } + return "Settings. Control size " + controls.controlSizeLabel() + + ". Control opacity " + controls.controlOpacityLabel() + + ". Vibration " + controls.hapticsLabel() + + ". Text size " + accessibility.textSizeLabel() + + ". High contrast " + accessibility.highContrastLabel() + + ". Back."; + } + + public String playingDescription(int levelNumber, int deaths, String elapsed, + String criticalState, boolean paused) { + if (paused) { + return russian() + ? "Пауза на уровне " + levelNumber + ", " + levelTitle(levelNumber) + + ". Продолжить или вернуться к выбору уровня." + : "Paused on level " + levelNumber + ", " + levelTitle(levelNumber) + + ". Resume or return to level select."; + } + if (russian()) { + return "Идёт уровень " + levelNumber + ", " + levelTitle(levelNumber) + + ". Добровольцы " + deaths + ". Время " + elapsed + ". " + + criticalState + ". Движение влево и вправо, прыжок, заново, пауза."; + } + return "Playing level " + levelNumber + ", " + levelTitle(levelNumber) + + ". " + deaths + " volunteers. Time " + elapsed + ". " + criticalState + + ". Move left, move right, jump, restart, pause."; + } + + public String completeDescription(int levelNumber, int deaths, String elapsed, + boolean hasNextLevel) { + if (russian()) { + return "Уровень завершён. " + levelTitle(levelNumber) + ". Добровольцы " + + deaths + ". Время " + elapsed + + ". Выбор уровня. Повторить." + + (hasNextLevel ? " Следующий уровень." : ""); + } + return "Level complete. " + levelTitle(levelNumber) + ". " + deaths + + " volunteers. Time " + elapsed + ". Level select. Replay." + + (hasNextLevel ? " Next level." : ""); + } + + public String levelTitle(int oneBasedLevelNumber) { + return (russian() ? RU_TITLES : EN_TITLES)[levelIndex(oneBasedLevelNumber)]; + } + + public String levelHint(int oneBasedLevelNumber) { + return (russian() ? RU_HINTS : EN_HINTS)[levelIndex(oneBasedLevelNumber)]; + } + + private String volunteerWord(int count) { + if (!russian()) return count == 1 ? "volunteer" : "volunteers"; + int mod100 = Math.abs(count) % 100; + int mod10 = Math.abs(count) % 10; + if (mod100 >= 11 && mod100 <= 14) return "добровольцев"; + if (mod10 == 1) return "доброволец"; + if (mod10 >= 2 && mod10 <= 4) return "добровольца"; + return "добровольцев"; + } + + private static int validPreset(int index) { + return index >= 0 && index <= 2 ? index : 1; + } + + private static int levelIndex(int oneBasedLevelNumber) { + int index = oneBasedLevelNumber - 1; + return index >= 0 && index < EN_TITLES.length ? index : 0; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index 610967b..87dcde9 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -14,8 +14,10 @@ import android.util.SparseArray; import android.view.HapticFeedbackConstants; import android.view.MotionEvent; import android.view.View; +import android.view.accessibility.AccessibilityEvent; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -57,6 +59,8 @@ public final class GameView extends View implements Runnable { private final float firstMovingPlatformY; private final boolean playerOnMovingPlatform; private final boolean firstMovingPlatformPowered; + private final boolean firstMovingPlatformMovingRight; + private final boolean firstMovingPlatformMovingDown; private final float firstCorpseX; private final boolean playerOnGround; @@ -66,7 +70,7 @@ public final class GameView extends View implements Runnable { float playerX, float playerY, int deaths, int corpseCount) { this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber, elapsedMilliseconds, playerX, playerY, deaths, corpseCount, - Float.NaN, Float.NaN, false, false, Float.NaN, false); + Float.NaN, Float.NaN, false, false, false, Float.NaN, false); } AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen, @@ -77,6 +81,38 @@ public final class GameView extends View implements Runnable { boolean playerOnMovingPlatform, boolean firstMovingPlatformPowered, float firstCorpseX, boolean playerOnGround) { + this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber, + elapsedMilliseconds, playerX, playerY, deaths, corpseCount, + firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform, + firstMovingPlatformPowered, false, firstCorpseX, playerOnGround); + } + + 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, float firstCorpseX, + boolean playerOnGround) { + this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber, + elapsedMilliseconds, playerX, playerY, deaths, corpseCount, + firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform, + firstMovingPlatformPowered, firstMovingPlatformMovingRight, false, + firstCorpseX, playerOnGround); + } + + 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) { this.completed = completed; this.playerAlive = playerAlive; this.allDoorsOpen = allDoorsOpen; @@ -92,6 +128,8 @@ public final class GameView extends View implements Runnable { this.firstMovingPlatformY = firstMovingPlatformY; this.playerOnMovingPlatform = playerOnMovingPlatform; this.firstMovingPlatformPowered = firstMovingPlatformPowered; + this.firstMovingPlatformMovingRight = firstMovingPlatformMovingRight; + this.firstMovingPlatformMovingDown = firstMovingPlatformMovingDown; this.firstCorpseX = firstCorpseX; this.playerOnGround = playerOnGround; } @@ -111,6 +149,8 @@ public final class GameView extends View implements Runnable { public float firstMovingPlatformY() { return firstMovingPlatformY; } public boolean playerOnMovingPlatform() { return playerOnMovingPlatform; } public boolean firstMovingPlatformPowered() { return firstMovingPlatformPowered; } + public boolean firstMovingPlatformMovingRight() { return firstMovingPlatformMovingRight; } + public boolean firstMovingPlatformMovingDown() { return firstMovingPlatformMovingDown; } public float firstCorpseX() { return firstCorpseX; } public boolean playerOnGround() { return playerOnGround; } } @@ -161,6 +201,9 @@ public final class GameView extends View implements Runnable { 0, 0, 0, VIEW_HEIGHT, Color.rgb(20, 29, 55), Color.rgb(110, 81, 91), Shader.TileMode.CLAMP); private final ProgressStore progress; private final ControlSettingsStore controlSettingsStore; + private final OnboardingStore onboardingStore; + private final GameLanguageStore languageStore; + private final AccessibilitySettingsStore accessibilitySettingsStore; private final SparseArray pointers = new SparseArray<>(); private final Player player = new Player(); private final ArrayList corpses = new ArrayList<>(); @@ -168,6 +211,8 @@ public final class GameView extends View implements Runnable { private final Map toggleSignals = new HashMap<>(); private final Map timedSignals = new HashMap<>(); private final Map previousSwitchContact = new HashMap<>(); + private final AccessibilityStateCache accessibilityStateCache = + new AccessibilityStateCache(); private Screen screen = Screen.LEVELS; private LevelDefinition level; @@ -187,18 +232,40 @@ public final class GameView extends View implements Runnable { private float canvasOffsetX; private float canvasOffsetY; private ControlSettings controlSettings; + private OnboardingFlow onboardingFlow; + private CompletionResult completionResult; + private GameLanguage language; + private GameStrings strings; + private AccessibilitySettings accessibilitySettings; + private String cachedLevelHeader = ""; + private String cachedLevelHint = ""; + private float cachedLevelHintSize = 25f; + private int cachedHudLevelNumber = -1; + private GameLanguage cachedHudLanguage; + private float cachedHudTextScale = Float.NaN; + private String cachedHudStats = ""; + private long cachedHudStatsSecond = Long.MIN_VALUE; + private int cachedHudStatsDeaths = Integer.MIN_VALUE; + private GameLanguage cachedHudStatsLanguage; public GameView(Context context) { super(context); progress = new ProgressStore(context); controlSettingsStore = new ControlSettingsStore(context); controlSettings = controlSettingsStore.load(); + onboardingStore = new OnboardingStore(context); + onboardingFlow = OnboardingFlow.firstRun(onboardingStore.isComplete()); + languageStore = new GameLanguageStore(context); + language = languageStore.load(); + strings = GameStrings.forLanguage(language); + accessibilitySettingsStore = new AccessibilitySettingsStore(context); + accessibilitySettings = accessibilitySettingsStore.load(); paint.setTypeface(android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL)); stroke.setStyle(Paint.Style.STROKE); stroke.setStrokeCap(Paint.Cap.ROUND); setFocusable(true); setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); - setContentDescription("Project Sacrifice. Ten-level puzzle platform game."); + refreshAccessibilityDescription(false); running = true; postOnAnimation(this); } @@ -227,16 +294,18 @@ public final class GameView extends View implements Runnable { pointers.clear(); deriveControls(); lastFrameNanos = System.nanoTime(); + refreshAccessibilityDescription(false); } public boolean handleBack() { if (screen == Screen.LEVELS) return false; if (screen == Screen.SETTINGS) { screen = Screen.LEVELS; + refreshAccessibilityDescription(true); return true; } if (screen == Screen.COMPLETE) { - screen = Screen.LEVELS; + levelsFromResult(); return true; } if (userPaused) { @@ -246,6 +315,7 @@ public final class GameView extends View implements Runnable { } pointers.clear(); deriveControls(); + refreshAccessibilityDescription(true); return true; } @@ -265,9 +335,13 @@ public final class GameView extends View implements Runnable { toggleSignals.clear(); timedSignals.clear(); previousSwitchContact.clear(); + completionResult = null; + configureAccessibilitySignals(); + invalidateHudTextCache(); for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item)); spawnPlayer(); lastFrameNanos = System.nanoTime(); + refreshAccessibilityDescription(true); } private void restartLevel() { @@ -307,6 +381,8 @@ public final class GameView extends View implements Runnable { checkGoal(); } + refreshAccessibilityDescriptionIfStateChanged(); + float targetCamera = player.alive ? player.x - 610f + player.facing * 110f : cameraX; targetCamera = GameMath.clamp(targetCamera, 0f, Math.max(0f, level.worldWidth - VIEW_WIDTH)); cameraX += (targetCamera - cameraX) * Math.min(1f, dt * 4.5f); @@ -622,12 +698,28 @@ public final class GameView extends View implements Runnable { private void checkGoal() { if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, level.goal)) return; - progress.recordCompletion(level.number, deaths, (long) (levelElapsed * 1000f)); + finishLevel(deaths, (long) (levelElapsed * 1000f)); + } + + private void finishLevel(int completedDeaths, long completedTimeMs) { + long safeTimeMs = Math.max(0L, completedTimeMs); + deaths = Math.max(0, completedDeaths); + levelElapsed = safeTimeMs / 1_000f; + progress.recordCompletion(level.number, deaths, safeTimeMs); + completionResult = CompletionResult.create( + level, + deaths, + safeTimeMs, + progress.bestDeaths(level.number), + progress.bestTimeMs(level.number), + levelIndex + 1 < LevelCatalog.all().size() + ); player.vx = player.vy = 0f; screen = Screen.COMPLETE; pointers.clear(); deriveControls(); emitHaptic(HapticFeedbackConstants.LONG_PRESS); + refreshAccessibilityDescription(true); } private RectF playerRect() { @@ -645,9 +737,7 @@ public final class GameView extends View implements Runnable { @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); - canvasScale = Math.min(getWidth() / VIEW_WIDTH, getHeight() / VIEW_HEIGHT); - canvasOffsetX = (getWidth() - VIEW_WIDTH * canvasScale) * 0.5f; - canvasOffsetY = (getHeight() - VIEW_HEIGHT * canvasScale) * 0.5f; + updateViewport(getWidth(), getHeight()); paint.setShader(outsideGradient); canvas.drawRect(0, 0, getWidth(), getHeight(), paint); @@ -664,6 +754,7 @@ public final class GameView extends View implements Runnable { drawGame(canvas); if (screen == Screen.COMPLETE) drawComplete(canvas); else if (userPaused) drawPause(canvas); + else if (onboardingFlow.isVisible()) drawOnboarding(canvas); } canvas.restore(); } @@ -685,6 +776,12 @@ public final class GameView extends View implements Runnable { } private void drawSky(Canvas canvas) { + if (accessibilitySettings.highContrastEnabled()) { + canvas.drawColor(Color.BLACK); + paint.setColor(Color.rgb(255, 214, 77)); + canvas.drawCircle(1560, 170, 88, paint); + return; + } paint.setShader(skyGradient); canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint); paint.setShader(null); @@ -716,10 +813,19 @@ public final class GameView extends View implements Runnable { for (PlatformState state : platformStates) { RectF box = state.rect(); boolean moving = state.definition.isMoving(); - paint.setColor(moving ? Color.rgb(172, 126, 55) : Color.rgb(69, 74, 91)); + paint.setColor(accessibilitySettings.highContrastEnabled() + ? (moving ? Color.rgb(255, 191, 0) : Color.rgb(56, 56, 56)) + : (moving ? Color.rgb(172, 126, 55) : Color.rgb(69, 74, 91))); canvas.drawRoundRect(box, 10, 10, paint); - paint.setColor(moving ? Color.rgb(242, 195, 92) : Color.rgb(117, 121, 137)); + paint.setColor(accessibilitySettings.highContrastEnabled() + ? Color.WHITE + : (moving ? Color.rgb(242, 195, 92) : Color.rgb(117, 121, 137))); canvas.drawRoundRect(new RectF(box.left, box.top, box.right, box.top + Math.min(14f, box.height())), 8, 8, paint); + if (accessibilitySettings.highContrastEnabled()) { + stroke.setColor(Color.WHITE); + stroke.setStrokeWidth(5f); + canvas.drawRoundRect(box, 10, 10, stroke); + } if (moving) { stroke.setColor(Color.argb(120, 244, 214, 137)); stroke.setStrokeWidth(5f); @@ -742,6 +848,7 @@ public final class GameView extends View implements Runnable { canvas.drawRoundRect(new RectF(box.left - 8, box.top + 6, box.right + 8, box.bottom + 12), 6, 6, paint); paint.setColor(active ? Color.rgb(250, 194, 76) : Color.rgb(174, 126, 55)); canvas.drawRoundRect(box, 7, 7, paint); + drawWorldLabel(canvas, active ? "DOWN" : "UP", box.centerX(), box.top - 18); } else { paint.setColor(Color.rgb(50, 56, 70)); canvas.drawRoundRect(box, 12, 12, paint); @@ -756,6 +863,7 @@ public final class GameView extends View implements Runnable { Float remaining = timedSignals.get(item.signalId); drawWorldLabel(canvas, String.format(Locale.US, "%.1f", remaining == null ? 0f : remaining), box.centerX(), box.top - 52); } + drawWorldLabel(canvas, active ? "ON" : "OFF", box.centerX(), box.bottom + 32); } } } @@ -848,6 +956,7 @@ public final class GameView extends View implements Runnable { paint.setColor(Color.rgb(239, 195, 92)); canvas.drawCircle(box.centerX(), box.centerY(), 15, paint); } + drawWorldLabel(canvas, open ? "OPEN" : "LOCKED", box.centerX(), box.top - 20); } } @@ -918,13 +1027,27 @@ public final class GameView extends View implements Runnable { } private void drawHud(Canvas canvas) { + ensureHudTextCache(); paint.setColor(Color.argb(178, 15, 20, 34)); canvas.drawRoundRect(new RectF(28, 26, 940, 138), 24, 24, paint); - drawText(canvas, String.format(Locale.US, "%02d %s", level.number, level.title), 56, 72, 34, Color.rgb(248, 220, 151), false); - drawText(canvas, level.hint, 56, 116, 25, Color.rgb(231, 231, 235), false); - drawText(canvas, "Volunteers " + deaths + " Time " + GameMath.formatTime((long) (levelElapsed * 1000)), 1010, 82, 31, Color.WHITE, false); + canvas.save(); + canvas.clipRect(40, 30, 925, 135); + drawText(canvas, cachedLevelHeader, 56, 72, 34, Color.rgb(248, 220, 151), + false); + drawText(canvas, cachedLevelHint, 56, 116, cachedLevelHintSize, + Color.rgb(231, 231, 235), false); + canvas.restore(); + canvas.save(); + canvas.clipRect(990, 28, 1590, 130); + drawText(canvas, hudStatsText(), 1010, 82, 31, Color.WHITE, false); + canvas.restore(); + paint.setColor(accessibilitySettings.highContrastEnabled() + ? Color.BLACK : Color.argb(178, 15, 20, 34)); + canvas.drawRoundRect(new RectF(590, 139, 1330, 185), 18, 18, paint); + drawCenteredText(canvas, criticalStatusText(), VIEW_WIDTH * 0.5f, 170, 22, + accessibilitySettings.highContrastEnabled() ? Color.WHITE : Color.rgb(218, 221, 230)); - drawHudButton(canvas, new RectF(1608, 26, 1770, 112), "Restart", false); + drawHudButton(canvas, new RectF(1608, 26, 1770, 112), strings.restart(), false); drawHudButton(canvas, new RectF(1790, 26, 1892, 112), "II", false); float controlScale = controlSettings.controlScale(); @@ -932,18 +1055,23 @@ public final class GameView extends View implements Runnable { drawControlCircle(canvas, 365, 925, 92 * controlScale, "›", rightPressed); drawControlCircle(canvas, 1760, 900, 116 * controlScale, "▲", jumpPressed); if (!player.alive) { - drawCenteredText(canvas, "Another volunteer is on the way…", VIEW_WIDTH * 0.5f, 235, 38, Color.rgb(250, 222, 153)); + drawCenteredText(canvas, strings.respawning(), VIEW_WIDTH * 0.5f, 235, 38, + Color.rgb(250, 222, 153)); } } private void drawControlCircle(Canvas canvas, float x, float y, float radius, String label, boolean active) { int inactiveFillAlpha = Math.round(190f * controlSettings.controlOpacity()); int inactiveStrokeAlpha = Math.round(230f * controlSettings.controlOpacity()); - paint.setColor(active ? Color.argb(220, 245, 190, 74) - : Color.argb(inactiveFillAlpha, 22, 28, 43)); + paint.setColor(accessibilitySettings.highContrastEnabled() + ? (active ? Color.rgb(255, 214, 77) : Color.BLACK) + : (active ? Color.argb(220, 245, 190, 74) + : Color.argb(inactiveFillAlpha, 22, 28, 43))); canvas.drawCircle(x, y, radius, paint); - stroke.setColor(active ? Color.rgb(255, 232, 169) - : Color.argb(inactiveStrokeAlpha, 240, 240, 245)); + stroke.setColor(accessibilitySettings.highContrastEnabled() + ? Color.WHITE + : (active ? Color.rgb(255, 232, 169) + : Color.argb(inactiveStrokeAlpha, 240, 240, 245))); stroke.setStrokeWidth(5f); canvas.drawCircle(x, y, radius - 5, stroke); drawCenteredText(canvas, label, x, y + 21, 68 * controlSettings.controlScale(), @@ -952,8 +1080,10 @@ public final class GameView extends View implements Runnable { private void drawLevelSelect(Canvas canvas) { drawSky(canvas); - drawCenteredText(canvas, "PROJECT SACRIFICE", VIEW_WIDTH * 0.5f, 120, 64, Color.rgb(248, 212, 126)); - drawCenteredText(canvas, "Every ending leaves a way forward", VIEW_WIDTH * 0.5f, 177, 29, Color.rgb(225, 226, 233)); + drawCenteredText(canvas, strings.projectTitle(), VIEW_WIDTH * 0.5f, 120, 64, + Color.rgb(248, 212, 126)); + drawCenteredText(canvas, strings.tagline(), VIEW_WIDTH * 0.5f, 177, 29, + Color.rgb(225, 226, 233)); int unlocked = progress.unlockedCount(); float startX = 110f; float cardW = 310f; @@ -967,87 +1097,227 @@ public final class GameView extends View implements Runnable { RectF card = new RectF(left, top, left + cardW, top + cardH); boolean enabled = i < unlocked; boolean complete = progress.isCompleted(i + 1); - paint.setColor(enabled ? Color.argb(220, 38, 45, 65) : Color.argb(185, 25, 29, 42)); + paint.setColor(accessibilitySettings.highContrastEnabled() + ? Color.BLACK + : (enabled ? Color.argb(220, 38, 45, 65) : Color.argb(185, 25, 29, 42))); canvas.drawRoundRect(card, 22, 22, paint); stroke.setColor(complete ? Color.rgb(109, 222, 143) : (enabled ? Color.rgb(205, 164, 83) : Color.rgb(73, 77, 91))); stroke.setStrokeWidth(5f); canvas.drawRoundRect(card, 22, 22, stroke); - drawCenteredText(canvas, enabled ? String.format(Locale.US, "%02d", i + 1) : "LOCKED", card.centerX(), top + 72, enabled ? 48 : 27, + canvas.save(); + canvas.clipRect(card.left + 10, card.top + 8, card.right - 10, card.bottom - 8); + drawCenteredText(canvas, enabled ? String.format(Locale.US, "%02d", i + 1) + : strings.locked(), card.centerX(), top + 72, enabled ? 48 : 27, enabled ? Color.rgb(248, 210, 118) : Color.rgb(120, 123, 135)); LevelDefinition item = LevelCatalog.get(i); - drawCenteredText(canvas, item.title, card.centerX(), top + 124, 28, enabled ? Color.WHITE : Color.rgb(111, 114, 125)); + drawCenteredFittedText(canvas, strings.levelTitle(item.number), card.centerX(), + top + 124, 28, 20, cardW - 28, + enabled ? Color.WHITE : Color.rgb(111, 114, 125)); if (complete) { int bestDeaths = progress.bestDeaths(i + 1); long bestTime = progress.bestTimeMs(i + 1); - drawCenteredText(canvas, "✓ " + bestDeaths + " volunteers · " + GameMath.formatTime(bestTime), card.centerX(), top + 185, 21, Color.rgb(138, 231, 163)); + drawCenteredFittedText(canvas, strings.levelBestStats(bestDeaths, bestTime), + card.centerX(), top + 185, 21, 17, cardW - 24, + Color.rgb(138, 231, 163)); } else if (enabled) { - drawCenteredText(canvas, "Tap to enter", card.centerX(), top + 190, 22, Color.rgb(202, 204, 214)); + drawCenteredText(canvas, strings.tapToEnter(), card.centerX(), top + 190, + 22, Color.rgb(202, 204, 214)); } + canvas.restore(); } - drawHudButton(canvas, new RectF(1560, 70, 1850, 160), "Settings", false); - drawCenteredText(canvas, "Landscape · multitouch · progress saves automatically", VIEW_WIDTH * 0.5f, 980, 24, Color.argb(190, 230, 230, 236)); + drawHudButton(canvas, new RectF(1560, 70, 1850, 160), strings.settingsButton(), false); + drawCenteredText(canvas, strings.levelSelectFooter(), VIEW_WIDTH * 0.5f, 980, 24, + Color.argb(190, 230, 230, 236)); } private void drawSettings(Canvas canvas) { drawSky(canvas); - drawCenteredText(canvas, "SETTINGS", VIEW_WIDTH * 0.5f, 145, 64, + drawCenteredText(canvas, strings.settingsTitle(), VIEW_WIDTH * 0.5f, 145, 64, Color.rgb(248, 212, 126)); - drawCenteredText(canvas, "Tap a row to change it. Changes save automatically.", + drawCenteredText(canvas, strings.settingsInstruction(), VIEW_WIDTH * 0.5f, 205, 27, Color.rgb(225, 226, 233)); - drawSettingRow(canvas, new RectF(420, 280, 1500, 410), "Control size", - controlSettings.controlSizeLabel()); - drawSettingRow(canvas, new RectF(420, 450, 1500, 580), "Control opacity", - controlSettings.controlOpacityLabel()); - drawSettingRow(canvas, new RectF(420, 620, 1500, 750), "Vibration", - controlSettings.hapticsLabel()); - drawHudButton(canvas, new RectF(710, 850, 1210, 960), "Back", true); + drawText(canvas, strings.languageSetting(), 1_370f, 126f, 27f, Color.WHITE, true); + drawHudButton(canvas, new RectF(1580, 70, 1850, 160), strings.languageName(), false); + drawSettingRow(canvas, new RectF(420, 280, 1500, 410), strings.controlSizeSetting(), + strings.controlSizeValue(controlSettings.controlSizeIndex())); + drawSettingRow(canvas, new RectF(420, 450, 1500, 580), strings.controlOpacitySetting(), + strings.controlOpacityValue(controlSettings.controlOpacityIndex())); + drawSettingRow(canvas, new RectF(420, 620, 1500, 750), strings.vibrationSetting(), + strings.hapticsValue(controlSettings.hapticsEnabled())); + drawCenteredText(canvas, strings.accessibilityTitle(), 220, 240, 25, + Color.rgb(248, 212, 126)); + drawCompactSettingRow(canvas, new RectF(55, 280, 385, 410), + strings.textSizeSetting(), + strings.textSizeValue(accessibilitySettings.textScale())); + drawCompactSettingRow(canvas, new RectF(55, 450, 385, 580), + strings.highContrastSetting(), + strings.highContrastValue(accessibilitySettings.highContrastEnabled())); + drawHudButton(canvas, new RectF(710, 850, 1210, 960), strings.back(), true); + } + + private void drawCompactSettingRow(Canvas canvas, RectF box, String label, String value) { + paint.setColor(accessibilitySettings.highContrastEnabled() + ? Color.BLACK : Color.argb(220, 38, 45, 65)); + canvas.drawRoundRect(box, 22, 22, paint); + stroke.setColor(accessibilitySettings.highContrastEnabled() + ? Color.WHITE : Color.rgb(139, 145, 160)); + stroke.setStrokeWidth(4f); + canvas.drawRoundRect(box, 22, 22, stroke); + canvas.save(); + canvas.clipRect(box.left + 8f, box.top + 8f, box.right - 8f, box.bottom - 8f); + drawCenteredText(canvas, label, box.centerX(), box.top + 47f, 24f, Color.WHITE); + drawCenteredText(canvas, value, box.centerX(), box.top + 98f, 28f, + Color.rgb(248, 212, 126)); + canvas.restore(); } private void drawSettingRow(Canvas canvas, RectF box, String label, String value) { - paint.setColor(Color.argb(220, 38, 45, 65)); + paint.setColor(accessibilitySettings.highContrastEnabled() + ? Color.BLACK : Color.argb(220, 38, 45, 65)); canvas.drawRoundRect(box, 22, 22, paint); - stroke.setColor(Color.rgb(139, 145, 160)); + stroke.setColor(accessibilitySettings.highContrastEnabled() + ? Color.WHITE : Color.rgb(139, 145, 160)); stroke.setStrokeWidth(4f); canvas.drawRoundRect(box, 22, 22, stroke); + canvas.save(); + canvas.clipRect(box.left + 10f, box.top + 8f, box.right - 10f, box.bottom - 8f); drawText(canvas, label, box.left + 55f, box.centerY() + 11f, 32f, Color.WHITE, true); drawHudButton(canvas, new RectF(1_090f, box.top + 24f, 1_445f, box.bottom - 24f), value, false); + canvas.restore(); } private void drawComplete(Canvas canvas) { paint.setColor(Color.argb(218, 8, 12, 23)); canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint); - drawCenteredText(canvas, "RELIC CLAIMED", VIEW_WIDTH * 0.5f, 270, 64, Color.rgb(250, 211, 111)); - drawCenteredText(canvas, level.title, VIEW_WIDTH * 0.5f, 335, 34, Color.WHITE); + drawCenteredText(canvas, strings.relicClaimed(), VIEW_WIDTH * 0.5f, 205, 64, + Color.rgb(250, 211, 111)); + drawCenteredText(canvas, strings.levelTitle(level.number), VIEW_WIDTH * 0.5f, 263, + 34, Color.WHITE); - boolean deathStar = deaths <= level.targetDeaths; - boolean timeStar = levelElapsed <= level.targetSeconds; - drawCenteredText(canvas, "Completion ★", VIEW_WIDTH * 0.5f, 470, 38, Color.rgb(250, 211, 111)); - drawCenteredText(canvas, (deathStar ? "★" : "☆") + " " + deaths + " / " + level.targetDeaths + " volunteers", - VIEW_WIDTH * 0.5f, 535, 31, deathStar ? Color.rgb(128, 229, 153) : Color.rgb(210, 211, 218)); - drawCenteredText(canvas, (timeStar ? "★" : "☆") + " " + GameMath.formatTime((long) (levelElapsed * 1000)) + " / " + GameMath.formatTime(level.targetSeconds * 1000L), - VIEW_WIDTH * 0.5f, 588, 31, timeStar ? Color.rgb(128, 229, 153) : Color.rgb(210, 211, 218)); + CompletionResult result = completionResult; + if (result != null) { + drawCenteredText(canvas, strings.currentResultLine(result.currentDeaths(), + result.currentTimeMs()), + VIEW_WIDTH * 0.5f, 380, 34, Color.WHITE); + drawCenteredText(canvas, strings.bestResultLine(result.bestDeaths(), + result.bestTimeMs()), + VIEW_WIDTH * 0.5f, 437, 31, Color.rgb(128, 229, 153)); + drawCenteredText(canvas, strings.targetsResultLine(result.targetDeaths(), + result.targetTimeMs()), + VIEW_WIDTH * 0.5f, 510, 28, Color.rgb(210, 211, 218)); + drawCenteredText(canvas, nextGoalText(result), VIEW_WIDTH * 0.5f, 585, 31, + Color.rgb(250, 211, 111)); + } - drawHudButton(canvas, new RectF(345, 735, 755, 850), "Level select", false); - drawHudButton(canvas, new RectF(775, 735, 1145, 850), "Replay", false); - if (levelIndex + 1 < LevelCatalog.all().size()) drawHudButton(canvas, new RectF(1165, 735, 1575, 850), "Next level", true); + drawHudButton(canvas, new RectF(345, 735, 755, 850), strings.levelSelect(), false); + drawHudButton(canvas, new RectF(775, 735, 1145, 850), strings.replay(), false); + if (levelIndex + 1 < LevelCatalog.all().size()) { + drawHudButton(canvas, new RectF(1165, 735, 1575, 850), strings.nextLevel(), true); + } + } + + private String nextGoalText(CompletionResult result) { + switch (result.nextGoal()) { + case TARGET_DEATHS: + return strings.nextGoalDeaths(result.nextGoalValue()); + case TARGET_TIME: + return strings.nextGoalTime(result.nextGoalValue()); + case NEXT_LEVEL: + return strings.nextGoalLevel(result.nextGoalValue()); + case PERSONAL_BEST: + default: + return strings.nextGoalPersonalBest(); + } + } + + private void drawOnboarding(Canvas canvas) { + RectF card = new RectF(535, 175, 1385, 375); + paint.setColor(Color.argb(232, 16, 22, 37)); + canvas.drawRoundRect(card, 24, 24, paint); + stroke.setColor(Color.rgb(226, 169, 63)); + stroke.setStrokeWidth(4f); + canvas.drawRoundRect(card, 24, 24, stroke); + + String title; + String message; + String progressLabel; + switch (onboardingFlow.step()) { + case MOVEMENT: + title = strings.tutorialMovementTitle(); + message = strings.tutorialMovementBody(); + progressLabel = strings.tutorialProgress(1, 3); + break; + case MULTITOUCH_JUMP: + title = strings.tutorialJumpTitle(); + message = strings.tutorialJumpBody(); + progressLabel = strings.tutorialProgress(2, 3); + break; + case CORPSES_ARE_TOOLS: + default: + title = strings.tutorialCorpseTitle(); + message = strings.tutorialCorpseBody(); + progressLabel = strings.tutorialProgress(3, 3); + break; + } + drawFittedText(canvas, title, 575, 230, 30, 21, 740, + Color.rgb(250, 211, 111), true); + drawFittedText(canvas, message, 575, 278, 27, 18, 740, Color.WHITE, false); + drawText(canvas, progressLabel, 575, 337, 22, Color.rgb(180, 185, 198), false); + drawHudButton(canvas, new RectF(965, 298, 1125, 355), strings.tutorialSkip(), false); + drawHudButton(canvas, new RectF(1145, 298, 1345, 355), + onboardingFlow.step() == OnboardingFlow.Step.CORPSES_ARE_TOOLS + ? strings.tutorialDone() : strings.tutorialNext(), + true); + } + + private String onboardingTitle() { + switch (onboardingFlow.step()) { + case MOVEMENT: + return strings.tutorialMovementTitle(); + case MULTITOUCH_JUMP: + return strings.tutorialJumpTitle(); + case CORPSES_ARE_TOOLS: + default: + return strings.tutorialCorpseTitle(); + } + } + + private String onboardingBody() { + switch (onboardingFlow.step()) { + case MOVEMENT: + return strings.tutorialMovementBody(); + case MULTITOUCH_JUMP: + return strings.tutorialJumpBody(); + case CORPSES_ARE_TOOLS: + default: + return strings.tutorialCorpseBody(); + } } private void drawPause(Canvas canvas) { paint.setColor(Color.argb(218, 8, 12, 23)); canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint); - drawCenteredText(canvas, "PAUSED", VIEW_WIDTH * 0.5f, 350, 68, Color.rgb(248, 212, 126)); - drawHudButton(canvas, new RectF(610, 485, 1310, 610), "Resume", true); - drawHudButton(canvas, new RectF(610, 650, 1310, 775), "Level select", false); + drawCenteredText(canvas, strings.paused(), VIEW_WIDTH * 0.5f, 350, 68, + Color.rgb(248, 212, 126)); + drawHudButton(canvas, new RectF(610, 485, 1310, 610), strings.resume(), true); + drawHudButton(canvas, new RectF(610, 650, 1310, 775), strings.levelSelect(), false); } private void drawHudButton(Canvas canvas, RectF box, String label, boolean primary) { - paint.setColor(primary ? Color.rgb(226, 169, 63) : Color.argb(210, 45, 52, 71)); + paint.setColor(accessibilitySettings.highContrastEnabled() + ? (primary ? Color.rgb(255, 214, 77) : Color.BLACK) + : (primary ? Color.rgb(226, 169, 63) : Color.argb(210, 45, 52, 71))); canvas.drawRoundRect(box, 22, 22, paint); - stroke.setColor(primary ? Color.rgb(255, 224, 146) : Color.rgb(139, 145, 160)); + stroke.setColor(accessibilitySettings.highContrastEnabled() + ? Color.WHITE + : (primary ? Color.rgb(255, 224, 146) : Color.rgb(139, 145, 160))); stroke.setStrokeWidth(4f); canvas.drawRoundRect(box, 22, 22, stroke); + canvas.save(); + canvas.clipRect(box.left + 8f, box.top + 5f, box.right - 8f, box.bottom - 5f); drawCenteredText(canvas, label, box.centerX(), box.centerY() + 10, Math.min(31, box.height() * 0.34f), primary ? Color.rgb(35, 36, 43) : Color.WHITE); + canvas.restore(); } private void drawWorldLabel(Canvas canvas, String label, float x, float y) { @@ -1057,18 +1327,42 @@ public final class GameView extends View implements Runnable { private void drawText(Canvas canvas, String text, float x, float baseline, float size, int color, boolean bold) { paint.setShader(null); paint.setStyle(Paint.Style.FILL); - paint.setTextSize(size); - paint.setColor(color); + paint.setTextSize(accessibilitySettings.canvasTextSize(size)); + paint.setColor(accessibleTextColor(color)); paint.setTextAlign(Paint.Align.LEFT); paint.setTypeface(android.graphics.Typeface.create("sans", bold ? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL)); canvas.drawText(text, x, baseline, paint); } + private void drawFittedText(Canvas canvas, String text, float x, float baseline, + float preferredSize, float minimumSize, float maximumWidth, + int color, boolean bold) { + float size = fittedTextSize(text, preferredSize, minimumSize, maximumWidth, bold); + drawText(canvas, text, x, baseline, size, color, bold); + } + + private void drawCenteredFittedText(Canvas canvas, String text, float x, float baseline, + float preferredSize, float minimumSize, + float maximumWidth, int color) { + float size = fittedTextSize(text, preferredSize, minimumSize, maximumWidth, true); + drawCenteredText(canvas, text, x, baseline, size, color); + } + + private float fittedTextSize(String text, float preferredSize, float minimumSize, + float maximumWidth, boolean bold) { + paint.setTextSize(preferredSize); + paint.setTypeface(android.graphics.Typeface.create("sans", + bold ? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL)); + float measured = paint.measureText(text); + if (measured <= maximumWidth || measured <= 0f) return preferredSize; + return Math.max(minimumSize, preferredSize * maximumWidth / measured); + } + private void drawCenteredText(Canvas canvas, String text, float x, float baseline, float size, int color) { paint.setShader(null); paint.setStyle(Paint.Style.FILL); - paint.setTextSize(size); - paint.setColor(color); + paint.setTextSize(accessibilitySettings.canvasTextSize(size)); + paint.setColor(accessibleTextColor(color)); paint.setTextAlign(Paint.Align.CENTER); paint.setTypeface(android.graphics.Typeface.create("sans", android.graphics.Typeface.BOLD)); canvas.drawText(text, x, baseline, paint); @@ -1078,6 +1372,16 @@ public final class GameView extends View implements Runnable { return Color.argb((int) (255 * alpha), Color.red(color), Color.green(color), Color.blue(color)); } + private int accessibleTextColor(int color) { + if (!accessibilitySettings.highContrastEnabled()) return color; + int red = Color.red(color); + int green = Color.green(color); + int blue = Color.blue(color); + if (red < 90 && green < 90 && blue < 90) return Color.BLACK; + if (red > 180 && green > 140 && blue < 170) return Color.rgb(255, 214, 77); + return Color.WHITE; + } + @Override public boolean onTouchEvent(MotionEvent event) { PointF point = toCanvasPoint(event.getX(event.getActionIndex()), event.getY(event.getActionIndex())); @@ -1112,6 +1416,14 @@ public final class GameView extends View implements Runnable { return true; } + if (onboardingFlow.isVisible() && action == MotionEvent.ACTION_UP + && handleOnboardingTap(point.x, point.y)) { + performClick(); + pointers.clear(); + deriveControls(); + return true; + } + if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN) { if (contains(1608, 26, 1770, 112, point.x, point.y)) { restartLevel(); @@ -1121,6 +1433,7 @@ public final class GameView extends View implements Runnable { userPaused = true; pointers.clear(); deriveControls(); + refreshAccessibilityDescription(true); return true; } } @@ -1168,6 +1481,7 @@ public final class GameView extends View implements Runnable { if (contains(1560, 70, 1850, 160, x, y)) { screen = Screen.SETTINGS; emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); + refreshAccessibilityDescription(true); return; } float startX = 110f; @@ -1188,9 +1502,47 @@ public final class GameView extends View implements Runnable { } private void handleCompleteTap(float x, float y) { - if (contains(345, 735, 755, 850, x, y)) screen = Screen.LEVELS; - else if (contains(775, 735, 1145, 850, x, y)) restartLevel(); - else if (contains(1165, 735, 1575, 850, x, y) && levelIndex + 1 < LevelCatalog.all().size()) startLevel(levelIndex + 1); + if (contains(345, 735, 755, 850, x, y)) levelsFromResult(); + else if (contains(775, 735, 1145, 850, x, y)) replayFromResult(); + else if (contains(1165, 735, 1575, 850, x, y)) nextLevelFromResult(); + } + + private boolean handleOnboardingTap(float x, float y) { + if (contains(965, 298, 1125, 355, x, y)) { + onboardingFlow.skip(); + onboardingStore.markComplete(); + invalidate(); + return true; + } + if (contains(1145, 298, 1345, 355, x, y)) { + advanceOnboarding(); + return true; + } + return false; + } + + private void advanceOnboarding() { + onboardingFlow.next(); + if (onboardingFlow.isComplete()) onboardingStore.markComplete(); + invalidate(); + } + + private void levelsFromResult() { + if (screen != Screen.COMPLETE) return; + screen = Screen.LEVELS; + pointers.clear(); + deriveControls(); + refreshAccessibilityDescription(true); + } + + private void replayFromResult() { + if (screen == Screen.COMPLETE) restartLevel(); + } + + private void nextLevelFromResult() { + if (screen == Screen.COMPLETE && levelIndex + 1 < LevelCatalog.all().size()) { + startLevel(levelIndex + 1); + } } private void handlePauseTap(float x, float y) { @@ -1199,10 +1551,20 @@ public final class GameView extends View implements Runnable { userPaused = false; screen = Screen.LEVELS; } + refreshAccessibilityDescription(true); } private void handleSettingsTap(float x, float y) { - if (contains(420, 280, 1500, 410, x, y)) { + if (contains(55, 280, 385, 410, x, y)) { + updateAccessibilitySettings(accessibilitySettings.nextTextSize()); + emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); + } else if (contains(55, 450, 385, 580, x, y)) { + updateAccessibilitySettings(accessibilitySettings.toggleHighContrast()); + emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); + } else if (contains(1580, 70, 1850, 160, x, y)) { + updateLanguage(language.next()); + emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); + } else if (contains(420, 280, 1500, 410, x, y)) { updateControlSettings(controlSettings.nextControlSize()); emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); } else if (contains(420, 450, 1500, 580, x, y)) { @@ -1214,6 +1576,7 @@ public final class GameView extends View implements Runnable { } else if (contains(710, 850, 1210, 960, x, y)) { screen = Screen.LEVELS; emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); + refreshAccessibilityDescription(true); } } @@ -1225,6 +1588,12 @@ public final class GameView extends View implements Runnable { return new PointF((x - canvasOffsetX) / Math.max(0.001f, canvasScale), (y - canvasOffsetY) / Math.max(0.001f, canvasScale)); } + private void updateViewport(float width, float height) { + canvasScale = Math.min(width / VIEW_WIDTH, height / VIEW_HEIGHT); + canvasOffsetX = (width - VIEW_WIDTH * canvasScale) * 0.5f; + canvasOffsetY = (height - VIEW_HEIGHT * canvasScale) * 0.5f; + } + private static boolean contains(float left, float top, float right, float bottom, float x, float y) { return x >= left && x <= right && y >= top && y <= bottom; } @@ -1254,6 +1623,34 @@ public final class GameView extends View implements Runnable { return screen == Screen.SETTINGS; } + public boolean levelsVisibleForAutomation() { + return screen == Screen.LEVELS; + } + + public boolean onboardingVisibleForAutomation() { + return screen == Screen.PLAYING && onboardingFlow.isVisible(); + } + + public OnboardingFlow.Step onboardingStepForAutomation() { + return onboardingFlow.step(); + } + + public void resetOnboardingForAutomation() { + onboardingStore.resetForAutomation(); + onboardingFlow = OnboardingFlow.firstRun(false); + invalidate(); + } + + public void advanceOnboardingForAutomation() { + advanceOnboarding(); + } + + public void skipOnboardingForAutomation() { + onboardingFlow.skip(); + onboardingStore.markComplete(); + invalidate(); + } + public void resetControlSettingsForAutomation() { updateControlSettings(ControlSettings.defaults()); } @@ -1270,14 +1667,319 @@ public final class GameView extends View implements Runnable { updateControlSettings(controlSettings.toggleHaptics()); } + public void finishLevelForAutomation(int completedDeaths, long completedTimeMs) { + if (screen != Screen.PLAYING || level == null) { + throw new IllegalStateException("A level must be playing before it can finish"); + } + finishLevel(completedDeaths, completedTimeMs); + } + + public CompletionResult completionResultForAutomation() { + return completionResult; + } + + public boolean resultVisibleForAutomation() { + return screen == Screen.COMPLETE; + } + + public boolean levelCompletedForAutomation(int oneBasedLevelNumber) { + return progress.isCompleted(oneBasedLevelNumber); + } + + public void levelsFromResultForAutomation() { + levelsFromResult(); + } + + public void replayFromResultForAutomation() { + replayFromResult(); + } + + public void nextLevelFromResultForAutomation() { + nextLevelFromResult(); + } + + public GameLanguage languageForAutomation() { + return language; + } + + public void resetLanguageForAutomation() { + updateLanguage(GameLanguage.EN); + } + + public void selectNextLanguageForAutomation() { + updateLanguage(language.next()); + } + + public void setLanguageForAutomation(GameLanguage updated) { + updateLanguage(updated); + } + + /** Exact localized copy sources that the current Canvas state renders. */ + public List visibleLocalizedTextForAutomation() { + ArrayList visible = new ArrayList<>(); + if (screen == Screen.LEVELS) { + visible.add(strings.projectTitle()); + visible.add(strings.tagline()); + for (int i = 0; i < LevelCatalog.all().size(); i++) { + boolean enabled = i < progress.unlockedCount(); + visible.add(enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked()); + visible.add(strings.levelTitle(i + 1)); + if (progress.isCompleted(i + 1)) { + visible.add(strings.levelBestStats(progress.bestDeaths(i + 1), + progress.bestTimeMs(i + 1))); + } else if (enabled) { + visible.add(strings.tapToEnter()); + } + } + visible.add(strings.settingsButton()); + visible.add(strings.levelSelectFooter()); + } else if (screen == Screen.SETTINGS) { + visible.add(strings.settingsTitle()); + visible.add(strings.settingsInstruction()); + visible.add(strings.languageSetting()); + visible.add(strings.languageName()); + visible.add(strings.controlSizeSetting()); + visible.add(strings.controlSizeValue(controlSettings.controlSizeIndex())); + visible.add(strings.controlOpacitySetting()); + visible.add(strings.controlOpacityValue(controlSettings.controlOpacityIndex())); + visible.add(strings.vibrationSetting()); + visible.add(strings.hapticsValue(controlSettings.hapticsEnabled())); + visible.add(strings.accessibilityTitle()); + visible.add(strings.textSizeSetting()); + visible.add(strings.textSizeValue(accessibilitySettings.textScale())); + visible.add(strings.highContrastSetting()); + visible.add(strings.highContrastValue(accessibilitySettings.highContrastEnabled())); + visible.add(strings.back()); + } else if (level != null) { + visible.add(String.format(Locale.US, "%02d %s", level.number, + strings.levelTitle(level.number))); + visible.add(strings.levelHint(level.number)); + visible.add(strings.hudStats(deaths, (long) (levelElapsed * 1000))); + visible.add(strings.restart()); + if (!player.alive) visible.add(strings.respawning()); + if (screen == Screen.COMPLETE) { + visible.add(strings.relicClaimed()); + visible.add(strings.levelTitle(level.number)); + if (completionResult != null) { + visible.add(strings.currentResultLine(completionResult.currentDeaths(), + completionResult.currentTimeMs())); + visible.add(strings.bestResultLine(completionResult.bestDeaths(), + completionResult.bestTimeMs())); + visible.add(strings.targetsResultLine(completionResult.targetDeaths(), + completionResult.targetTimeMs())); + visible.add(nextGoalText(completionResult)); + } + visible.add(strings.levelSelect()); + visible.add(strings.replay()); + if (levelIndex + 1 < LevelCatalog.all().size()) visible.add(strings.nextLevel()); + } else if (userPaused) { + visible.add(strings.paused()); + visible.add(strings.resume()); + visible.add(strings.levelSelect()); + } else if (onboardingFlow.isVisible()) { + visible.add(onboardingTitle()); + visible.add(onboardingBody()); + visible.add(strings.tutorialSkip()); + visible.add(onboardingFlow.step() == OnboardingFlow.Step.CORPSES_ARE_TOOLS + ? strings.tutorialDone() : strings.tutorialNext()); + } + } + return Collections.unmodifiableList(visible); + } + + private void updateLanguage(GameLanguage updated) { + language = updated == null ? GameLanguage.EN : updated; + strings = GameStrings.forLanguage(language); + languageStore.save(language); + invalidateHudTextCache(); + refreshAccessibilityDescription(false); + invalidate(); + } + + public AccessibilitySettings accessibilitySettingsForAutomation() { + return accessibilitySettings; + } + + public void resetAccessibilitySettingsForAutomation() { + updateAccessibilitySettings(AccessibilitySettings.defaults()); + } + + public void selectNextTextSizeForAutomation() { + updateAccessibilitySettings(accessibilitySettings.nextTextSize()); + } + + public void toggleHighContrastForAutomation() { + updateAccessibilitySettings(accessibilitySettings.toggleHighContrast()); + } + + public float textScaleForAutomation() { + return accessibilitySettings.textScale(); + } + + public boolean highContrastForAutomation() { + return accessibilitySettings.highContrastEnabled(); + } + + public String screenDescriptionForAutomation() { + return currentAccessibilityDescription(); + } + + public String criticalVisibleStatusForAutomation() { + return criticalStatusText(); + } + + public void openSettingsForAutomation() { + screen = Screen.SETTINGS; + refreshAccessibilityDescription(false); + } + + public void pauseForAutomation() { + if (screen == Screen.PLAYING) { + userPaused = true; + pointers.clear(); + deriveControls(); + refreshAccessibilityDescription(false); + } + } + + public void showCompleteForAutomation() { + if (level != null) { + screen = Screen.COMPLETE; + pointers.clear(); + deriveControls(); + refreshAccessibilityDescription(false); + } + } + + public float[] safeViewportForAutomation() { + float width = getWidth(); + float height = getHeight(); + if (width <= 0f || height <= 0f) return new float[] {0f, 0f, 0f, 0f}; + float scale = Math.min(width / VIEW_WIDTH, height / VIEW_HEIGHT); + float left = (width - VIEW_WIDTH * scale) * 0.5f; + float top = (height - VIEW_HEIGHT * scale) * 0.5f; + return new float[] {left, top, left + VIEW_WIDTH * scale, top + VIEW_HEIGHT * scale}; + } + private void updateControlSettings(ControlSettings updated) { controlSettings = updated; controlSettingsStore.save(updated); pointers.clear(); deriveControls(); + refreshAccessibilityDescription(false); invalidate(); } + private void updateAccessibilitySettings(AccessibilitySettings updated) { + accessibilitySettings = updated; + accessibilitySettingsStore.save(updated); + invalidateHudTextCache(); + refreshAccessibilityDescription(false); + invalidate(); + } + + private void invalidateHudTextCache() { + cachedHudLevelNumber = -1; + cachedHudLanguage = null; + cachedHudTextScale = Float.NaN; + cachedHudStatsSecond = Long.MIN_VALUE; + cachedHudStatsDeaths = Integer.MIN_VALUE; + cachedHudStatsLanguage = null; + } + + private void ensureHudTextCache() { + if (level == null) return; + float textScale = accessibilitySettings.textScale(); + if (cachedHudLevelNumber == level.number + && cachedHudLanguage == language + && Float.compare(cachedHudTextScale, textScale) == 0) { + return; + } + cachedHudLevelNumber = level.number; + cachedHudLanguage = language; + cachedHudTextScale = textScale; + cachedLevelHeader = String.format(Locale.US, "%02d %s", level.number, + strings.levelTitle(level.number)); + cachedLevelHint = strings.levelHint(level.number); + cachedLevelHintSize = fittedTextSize(cachedLevelHint, 25f, 16f, 850f, false); + } + + private String hudStatsText() { + long displayedSecond = (long) (levelElapsed * 1000f) / 1000L; + if (cachedHudStatsSecond != displayedSecond + || cachedHudStatsDeaths != deaths + || cachedHudStatsLanguage != language) { + cachedHudStatsSecond = displayedSecond; + cachedHudStatsDeaths = deaths; + cachedHudStatsLanguage = language; + cachedHudStats = strings.hudStats(deaths, displayedSecond * 1000L); + } + return cachedHudStats; + } + + private void configureAccessibilitySignals() { + ArrayList signalIds = new ArrayList<>(); + if (level != null) { + for (LevelDefinition.Switch item : level.switches) signalIds.add(item.signalId); + } + accessibilityStateCache.configureSignals(signalIds); + } + + private boolean updateCriticalStatusCache() { + int activeSignals = 0; + if (level != null) { + for (String signalId : accessibilityStateCache.signalIds()) { + if (signalActive(signalId)) activeSignals++; + } + } + int openDoors = 0; + if (level != null) { + for (LevelDefinition.Door door : level.doors) if (doorOpen(door)) openDoors++; + } + return accessibilityStateCache.update(strings, activeSignals, openDoors, + level == null ? 0 : level.doors.size(), level == null || player.alive); + } + + private String criticalStatusText() { + if (accessibilityStateCache.statusText() == null) updateCriticalStatusCache(); + return accessibilityStateCache.statusText(); + } + + private String currentAccessibilityDescription() { + if (screen == Screen.LEVELS) { + return AccessibilityScreenDescription.levelSelect(strings, progress.unlockedCount(), + LevelCatalog.all().size()); + } + if (screen == Screen.SETTINGS) { + return AccessibilityScreenDescription.settings(strings, controlSettings, + accessibilitySettings); + } + if (screen == Screen.COMPLETE) { + return AccessibilityScreenDescription.complete(strings, level, deaths, + GameMath.formatTime((long) (levelElapsed * 1000f)), + levelIndex + 1 < LevelCatalog.all().size()); + } + return AccessibilityScreenDescription.playing(strings, level, deaths, + GameMath.formatTime((long) (levelElapsed * 1000f)), criticalStatusText(), + userPaused); + } + + private void refreshAccessibilityDescriptionIfStateChanged() { + if (updateCriticalStatusCache()) applyAccessibilityDescription(false); + } + + private void refreshAccessibilityDescription(boolean announce) { + updateCriticalStatusCache(); + applyAccessibilityDescription(announce); + } + + private void applyAccessibilityDescription(boolean announce) { + setContentDescription(currentAccessibilityDescription()); + if (announce && isAttachedToWindow()) { + sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); + } + } + public void setAutomationInput(boolean moveLeft, boolean moveRight, boolean jump) { boolean jumpWasPressed = jumpPressed; leftPressed = moveLeft; @@ -1294,11 +1996,15 @@ public final class GameView extends View implements Runnable { float firstMovingPlatformX = Float.NaN; float firstMovingPlatformY = Float.NaN; boolean firstMovingPlatformPowered = false; + boolean firstMovingPlatformMovingRight = false; + boolean firstMovingPlatformMovingDown = false; for (PlatformState state : platformStates) { if (state.definition.isMoving()) { firstMovingPlatformX = state.x; firstMovingPlatformY = state.y; firstMovingPlatformPowered = signalActive(state.definition.controlId); + firstMovingPlatformMovingRight = state.x > state.previousX; + firstMovingPlatformMovingDown = state.y > state.previousY; break; } } @@ -1322,6 +2028,8 @@ public final class GameView extends View implements Runnable { firstMovingPlatformY, playerOnMovingPlatform, firstMovingPlatformPowered, + firstMovingPlatformMovingRight, + firstMovingPlatformMovingDown, corpses.isEmpty() ? Float.NaN : corpses.get(0).x, player.onGround ); diff --git a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java index e04a067..dda0763 100644 --- a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java +++ b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java @@ -6,6 +6,10 @@ import java.util.List; /** Runs black-box acceptance scenarios through the same input and state boundary as touch play. */ public final class LevelPlaytestBot { + private static final long DECISION_MILLIS = 16L; + private static final long MIN_WALL_WATCHDOG_MILLIS = 120_000L; + private static final long WALL_WATCHDOG_MULTIPLIER = 10L; + public interface Driver { void startLevel(int oneBasedLevelNumber); void setInput(boolean moveLeft, boolean moveRight, boolean jump); @@ -44,6 +48,27 @@ public final class LevelPlaytestBot { boolean reached(GameView.AutomationSnapshot snapshot); } + public static SnapshotCondition groundedAliveAtOrBeyond(float minimumX) { + return snapshot -> snapshot.playerAlive() + && snapshot.playerOnGround() + && snapshot.playerX() >= minimumX; + } + + public static SnapshotCondition whileDeathsAtMost( + int maximumDeaths, SnapshotCondition completion) { + return snapshot -> { + if (snapshot.deaths() > maximumDeaths) { + throw new AssertionError("Route expected at most " + maximumDeaths + + " deaths but observed " + snapshot.deaths() + + "; x=" + snapshot.playerX() + + ", y=" + snapshot.playerY() + + ", alive=" + snapshot.playerAlive() + + ", grounded=" + snapshot.playerOnGround()); + } + return completion.reached(snapshot); + }; + } + public static final class Scenario { private final int levelNumber; private final String name; @@ -123,22 +148,38 @@ public final class LevelPlaytestBot { public Result run(Driver driver, Scenario scenario, long timeoutMillis) { driver.startLevel(scenario.levelNumber); - long scenarioStartedAt = driver.currentTimeMillis(); - long stepStartedAt = scenarioStartedAt; + long scenarioStartedAtWall = driver.currentTimeMillis(); + long wallWatchdogMillis = wallWatchdogMillis(timeoutMillis); int stepIndex = 0; String lastStep = scenario.steps.isEmpty() ? null : scenario.steps.get(0).name; boolean doorOpened = false; GameView.AutomationSnapshot last = driver.snapshot(); + long scenarioStartedAtSimulation = last.elapsedMilliseconds(); + long stepStartedAtSimulation = scenarioStartedAtSimulation; - while (driver.currentTimeMillis() - scenarioStartedAt < timeoutMillis) { + while (true) { last = driver.snapshot(); + long wallElapsed = elapsed(driver.currentTimeMillis(), scenarioStartedAtWall); + long simulationElapsed = elapsed( + last.elapsedMilliseconds(), scenarioStartedAtSimulation); doorOpened |= last.allDoorsOpen(); if (last.completed()) { driver.setInput(false, false, false); return new Result(true, doorOpened, last.deaths(), last.corpseCount(), - scenario.name + " completed; step=" + lastStep + "; " + snapshotState(last), + scenario.name + " completed; step=" + lastStep + "; " + + timingState(simulationElapsed, wallElapsed) + "; " + + snapshotState(last), lastStep); } + if (wallElapsed >= wallWatchdogMillis) { + driver.setInput(false, false, false); + return new Result(false, doorOpened, last.deaths(), last.corpseCount(), + scenario.name + " stopped by wall-clock watchdog; step=" + lastStep + + "; " + timingState(simulationElapsed, wallElapsed) + "; " + + snapshotState(last), + lastStep); + } + if (simulationElapsed >= timeoutMillis) break; if (stepIndex >= scenario.steps.size()) break; Step step = scenario.steps.get(stepIndex); @@ -146,26 +187,50 @@ public final class LevelPlaytestBot { if (step.completion.reached(last)) { driver.setInput(false, false, false); stepIndex++; - stepStartedAt = driver.currentTimeMillis(); + stepStartedAtSimulation = last.elapsedMilliseconds(); continue; } - if (driver.currentTimeMillis() - stepStartedAt >= step.timeoutMillis) { + if (elapsed(last.elapsedMilliseconds(), stepStartedAtSimulation) + >= step.timeoutMillis) { driver.setInput(false, false, false); return new Result(false, doorOpened, last.deaths(), last.corpseCount(), - scenario.name + " timed out; step=" + step.name + "; " + snapshotState(last), + scenario.name + " timed out in simulation; step=" + step.name + + "; " + timingState(simulationElapsed, wallElapsed) + "; " + + snapshotState(last), step.name); } driver.setInput(step.input.moveLeft, step.input.moveRight, step.input.jump); - driver.waitForNextDecision(32L); + driver.waitForNextDecision(DECISION_MILLIS); } driver.setInput(false, false, false); + long wallElapsed = elapsed(driver.currentTimeMillis(), scenarioStartedAtWall); + long simulationElapsed = elapsed( + last.elapsedMilliseconds(), scenarioStartedAtSimulation); return new Result(false, doorOpened, last.deaths(), last.corpseCount(), - scenario.name + " did not complete; step=" + lastStep + "; " + snapshotState(last), + scenario.name + " exhausted its simulation budget; step=" + lastStep + + "; " + timingState(simulationElapsed, wallElapsed) + "; " + + snapshotState(last), lastStep); } + private static long wallWatchdogMillis(long simulationBudgetMillis) { + if (simulationBudgetMillis > Long.MAX_VALUE / WALL_WATCHDOG_MULTIPLIER) { + return Long.MAX_VALUE; + } + return Math.max(MIN_WALL_WATCHDOG_MILLIS, + simulationBudgetMillis * WALL_WATCHDOG_MULTIPLIER); + } + + private static long elapsed(long current, long startedAt) { + return Math.max(0L, current - startedAt); + } + + private static String timingState(long simulationElapsed, long wallElapsed) { + return "simulationElapsed=" + simulationElapsed + ", wallElapsed=" + wallElapsed; + } + private static String snapshotState(GameView.AutomationSnapshot snapshot) { return "x=" + snapshot.playerX() + ", y=" + snapshot.playerY() @@ -190,8 +255,10 @@ public final class LevelPlaytestBot { snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) .step("Approach the saw", 4_000L, Input.right(), snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 390f) - .step("Jump over the saw", 2_000L, Input.rightJump(), - snapshot -> snapshot.playerX() >= 430f) + .step("Take off before the saw", 1_000L, Input.rightJump(), + snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 700f) + .step("Clear the saw", 2_000L, Input.right(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f) .step("Run to the ledge", 4_000L, Input.right(), snapshot -> snapshot.playerX() >= 750f) .step("Jump onto the ledge", 2_000L, Input.rightJump(), diff --git a/app/src/main/java/com/projectsacrifice/game/MovingPlatformLaunchPolicy.java b/app/src/main/java/com/projectsacrifice/game/MovingPlatformLaunchPolicy.java new file mode 100644 index 0000000..448ba88 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/MovingPlatformLaunchPolicy.java @@ -0,0 +1,19 @@ +package com.projectsacrifice.game; + +/** Stable public-state gate for a single moving-platform launch. */ +final class MovingPlatformLaunchPolicy { + private MovingPlatformLaunchPolicy() {} + + static boolean readyToBoard(GameView.AutomationSnapshot snapshot, float maximumPlatformX) { + return snapshot.firstMovingPlatformPowered() + && snapshot.firstMovingPlatformMovingRight() + && snapshot.firstMovingPlatformX() <= maximumPlatformX; + } + + static boolean ready(GameView.AutomationSnapshot snapshot, float minimumPlatformX) { + return snapshot.playerOnMovingPlatform() + && snapshot.playerOnGround() + && snapshot.firstMovingPlatformPowered() + && snapshot.firstMovingPlatformX() >= minimumPlatformX; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/OnboardingFlow.java b/app/src/main/java/com/projectsacrifice/game/OnboardingFlow.java new file mode 100644 index 0000000..b8cad49 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/OnboardingFlow.java @@ -0,0 +1,45 @@ +package com.projectsacrifice.game; + +/** Small state machine for the optional, first-run gameplay hints. */ +public final class OnboardingFlow { + public enum Step { + MOVEMENT, + MULTITOUCH_JUMP, + CORPSES_ARE_TOOLS + } + + private Step step; + private boolean complete; + + private OnboardingFlow(boolean complete) { + this.complete = complete; + step = Step.MOVEMENT; + } + + public static OnboardingFlow firstRun(boolean alreadyComplete) { + return new OnboardingFlow(alreadyComplete); + } + + public boolean isVisible() { + return !complete; + } + + public boolean isComplete() { + return complete; + } + + public Step step() { + return step; + } + + public void next() { + if (complete) return; + if (step == Step.MOVEMENT) step = Step.MULTITOUCH_JUMP; + else if (step == Step.MULTITOUCH_JUMP) step = Step.CORPSES_ARE_TOOLS; + else complete = true; + } + + public void skip() { + complete = true; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/OnboardingStore.java b/app/src/main/java/com/projectsacrifice/game/OnboardingStore.java new file mode 100644 index 0000000..acc109e --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/OnboardingStore.java @@ -0,0 +1,33 @@ +package com.projectsacrifice.game; + +import android.content.Context; +import android.content.SharedPreferences; + +/** Versioned preference boundary for the optional first-run tutorial. */ +public final class OnboardingStore { + private static final String FILE = "project_sacrifice_onboarding_v1"; + private static final String KEY_COMPLETE = "complete"; + private static final String KEY_SCHEMA = "schema"; + private static final int SCHEMA_VERSION = 1; + + private final SharedPreferences preferences; + + public OnboardingStore(Context context) { + preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE); + } + + public boolean isComplete() { + return preferences.getBoolean(KEY_COMPLETE, false); + } + + public void markComplete() { + preferences.edit() + .putInt(KEY_SCHEMA, SCHEMA_VERSION) + .putBoolean(KEY_COMPLETE, true) + .apply(); + } + + public void resetForAutomation() { + preferences.edit().clear().commit(); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/PlaytestSimulationBudget.java b/app/src/main/java/com/projectsacrifice/game/PlaytestSimulationBudget.java new file mode 100644 index 0000000..e392692 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/PlaytestSimulationBudget.java @@ -0,0 +1,56 @@ +package com.projectsacrifice.game; + +/** Simulation-first timeout with a separate watchdog for a stalled device. */ +final class PlaytestSimulationBudget { + private static final long MIN_WALL_WATCHDOG_MILLIS = 120_000L; + private static final long WALL_WATCHDOG_MULTIPLIER = 10L; + + private final long simulationStartedAt; + private final long wallStartedAt; + private final long simulationBudgetMillis; + private final long wallWatchdogMillis; + + private PlaytestSimulationBudget(long simulationStartedAt, long wallStartedAt, + long simulationBudgetMillis) { + this.simulationStartedAt = simulationStartedAt; + this.wallStartedAt = wallStartedAt; + this.simulationBudgetMillis = simulationBudgetMillis; + this.wallWatchdogMillis = wallWatchdogMillis(simulationBudgetMillis); + } + + static PlaytestSimulationBudget start(long simulationStartedAt, long wallStartedAt, + long simulationBudgetMillis) { + return new PlaytestSimulationBudget( + simulationStartedAt, wallStartedAt, simulationBudgetMillis); + } + + boolean exhausted(long simulationNow, long wallNow) { + return simulationElapsed(simulationNow) >= simulationBudgetMillis + || wallElapsed(wallNow) >= wallWatchdogMillis; + } + + String timingState(long simulationNow, long wallNow) { + return "simulationElapsed=" + simulationElapsed(simulationNow) + + ", wallElapsed=" + wallElapsed(wallNow); + } + + private long simulationElapsed(long simulationNow) { + return elapsed(simulationNow, simulationStartedAt); + } + + private long wallElapsed(long wallNow) { + return elapsed(wallNow, wallStartedAt); + } + + private static long wallWatchdogMillis(long simulationBudgetMillis) { + if (simulationBudgetMillis > Long.MAX_VALUE / WALL_WATCHDOG_MULTIPLIER) { + return Long.MAX_VALUE; + } + return Math.max(MIN_WALL_WATCHDOG_MILLIS, + simulationBudgetMillis * WALL_WATCHDOG_MULTIPLIER); + } + + private static long elapsed(long current, long startedAt) { + return Math.max(0L, current - startedAt); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/AccessibilityScreenDescriptionTest.java b/app/src/test/java/com/projectsacrifice/game/AccessibilityScreenDescriptionTest.java new file mode 100644 index 0000000..8c6398b --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/AccessibilityScreenDescriptionTest.java @@ -0,0 +1,35 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public final class AccessibilityScreenDescriptionTest { + @Test + public void everyPlayerFacingScreenNamesItsStateAndAvailableActions() { + String levels = AccessibilityScreenDescription.levelSelect(3, 10); + assertTrue(levels.contains("Level select")); + assertTrue(levels.contains("3 of 10 unlocked")); + assertTrue(levels.contains("Settings")); + + String settings = AccessibilityScreenDescription.settings( + ControlSettings.defaults(), AccessibilitySettings.defaults()); + assertTrue(settings.contains("Settings")); + assertTrue(settings.contains("Text size Standard")); + assertTrue(settings.contains("High contrast Off")); + assertTrue(settings.contains("Back")); + + String playing = AccessibilityScreenDescription.playing( + LevelCatalog.get(0), 2, "01:23", "Signals 1/1", false); + assertTrue(playing.contains("Playing level 1")); + assertTrue(playing.contains("2 volunteers")); + assertTrue(playing.contains("Signals 1/1")); + assertTrue(playing.contains("Move left, move right, jump, restart, pause")); + + String complete = AccessibilityScreenDescription.complete( + LevelCatalog.get(0), 2, "01:23", true); + assertTrue(complete.contains("Level complete")); + assertTrue(complete.contains("Replay")); + assertTrue(complete.contains("Next level")); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/AccessibilitySettingsTest.java b/app/src/test/java/com/projectsacrifice/game/AccessibilitySettingsTest.java new file mode 100644 index 0000000..2cc0859 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/AccessibilitySettingsTest.java @@ -0,0 +1,53 @@ +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 AccessibilitySettingsTest { + @Test + public void textSizeCyclesBetweenReadablePresetsAndWraps() { + AccessibilitySettings settings = AccessibilitySettings.defaults(); + + assertEquals("Standard", settings.textSizeLabel()); + assertEquals(1f, settings.textScale(), 0f); + + settings = settings.nextTextSize(); + assertEquals("Large", settings.textSizeLabel()); + assertEquals(1.25f, settings.textScale(), 0f); + + assertEquals("Standard", settings.nextTextSize().textSizeLabel()); + } + + @Test + public void highContrastIsExplicitAndReversible() { + AccessibilitySettings settings = AccessibilitySettings.defaults(); + + assertFalse(settings.highContrastEnabled()); + assertEquals("Off", settings.highContrastLabel()); + + settings = settings.toggleHighContrast(); + assertTrue(settings.highContrastEnabled()); + assertEquals("On", settings.highContrastLabel()); + assertFalse(settings.toggleHighContrast().highContrastEnabled()); + } + + @Test + public void invalidPersistedPresetFallsBackWithoutChangingContrastChoice() { + AccessibilitySettings settings = AccessibilitySettings.restore(99, true); + + assertEquals("Standard", settings.textSizeLabel()); + assertTrue(settings.highContrastEnabled()); + } + + @Test + public void largePresetScalesEveryCanvasTextRequestWithoutMovingLayout() { + AccessibilitySettings large = AccessibilitySettings.defaults().nextTextSize(); + + assertEquals(30f, large.canvasTextSize(24f), 0f); + assertEquals(80f, large.canvasTextSize(64f), 0f); + assertEquals(24f, AccessibilitySettings.defaults().canvasTextSize(24f), 0f); + } + +} diff --git a/app/src/test/java/com/projectsacrifice/game/AccessibilityStateCacheTest.java b/app/src/test/java/com/projectsacrifice/game/AccessibilityStateCacheTest.java new file mode 100644 index 0000000..73de1c1 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/AccessibilityStateCacheTest.java @@ -0,0 +1,47 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertNotSame; + +import java.util.Arrays; + +import org.junit.Test; + +public final class AccessibilityStateCacheTest { + @Test + public void unchangedFramesReuseOneStatusAndOneDeduplicatedSignalSet() { + AccessibilityStateCache cache = new AccessibilityStateCache(); + cache.configureSignals(Arrays.asList("lift", "lift", "door")); + + assertEquals(Arrays.asList("lift", "door"), cache.signalIds()); + assertTrue(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 0, 1, true)); + String first = cache.statusText(); + + for (int frame = 0; frame < 600; frame++) { + assertFalse(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 0, 1, true)); + assertSame(first, cache.statusText()); + } + } + + @Test + public void criticalStateAndLanguageChangesInvalidateExactlyOnce() { + AccessibilityStateCache cache = new AccessibilityStateCache(); + cache.configureSignals(Arrays.asList("plate")); + cache.update(GameStrings.forLanguage(GameLanguage.EN), 0, 0, 1, true); + String initial = cache.statusText(); + + assertTrue(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 1, 1, false)); + String changed = cache.statusText(); + assertNotSame(initial, changed); + assertTrue(changed.contains("Signals 1/1")); + assertTrue(changed.contains("Volunteer respawning")); + assertFalse(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 1, 1, false)); + + assertTrue(cache.update(GameStrings.forLanguage(GameLanguage.RU), 1, 1, 1, false)); + assertTrue(cache.statusText().contains("Сигналы 1/1")); + assertFalse(cache.update(GameStrings.forLanguage(GameLanguage.RU), 1, 1, 1, false)); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/AccessibilityStatusTest.java b/app/src/test/java/com/projectsacrifice/game/AccessibilityStatusTest.java new file mode 100644 index 0000000..49c1d9b --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/AccessibilityStatusTest.java @@ -0,0 +1,19 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public final class AccessibilityStatusTest { + @Test + public void criticalGameplayStateIsRepresentedByTextNotOnlyColorOrVibration() { + assertEquals( + "Signals 1/2 · Doors 0/1 · Volunteer respawning", + AccessibilityStatus.describe(1, 2, 0, 1, false) + ); + assertEquals( + "Signals 0/0 · Doors 0/0 · Volunteer active", + AccessibilityStatus.describe(0, 0, 0, 0, true) + ); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/CompletionResultTest.java b/app/src/test/java/com/projectsacrifice/game/CompletionResultTest.java new file mode 100644 index 0000000..019f330 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/CompletionResultTest.java @@ -0,0 +1,48 @@ +package com.projectsacrifice.game; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public final class CompletionResultTest { + @Test + public void resultKeepsThisRunAndSavedBestSeparateAndSuggestsTheMissingTarget() { + LevelDefinition level = LevelCatalog.get(0); + + CompletionResult result = CompletionResult.create( + level, + 2, + 42_000L, + 0, + 25_000L, + true + ); + + assertEquals(2, result.currentDeaths()); + assertEquals(42_000L, result.currentTimeMs()); + assertEquals(0, result.bestDeaths()); + assertEquals(25_000L, result.bestTimeMs()); + assertEquals(CompletionResult.NextGoal.TARGET_DEATHS, result.nextGoal()); + assertEquals(0, result.nextGoalValue()); + } + + @Test + public void masteredLevelPointsAtTheNextLevelOrPersonalBest() { + LevelDefinition first = LevelCatalog.get(0); + CompletionResult timeTarget = CompletionResult.create(first, 0, 31_000L, + 0, 25_000L, true); + assertEquals(CompletionResult.NextGoal.TARGET_TIME, timeTarget.nextGoal()); + assertEquals(30_000L, timeTarget.nextGoalValue()); + + CompletionResult next = CompletionResult.create(first, 0, 25_000L, + 0, 25_000L, true); + assertEquals(CompletionResult.NextGoal.NEXT_LEVEL, next.nextGoal()); + assertEquals(2, next.nextGoalValue()); + + LevelDefinition last = LevelCatalog.get(LevelCatalog.all().size() - 1); + CompletionResult lastResult = CompletionResult.create(last, + last.targetDeaths, last.targetSeconds * 900L, + last.targetDeaths, last.targetSeconds * 900L, false); + assertEquals(CompletionResult.NextGoal.PERSONAL_BEST, lastResult.nextGoal()); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/GameStringsTest.java b/app/src/test/java/com/projectsacrifice/game/GameStringsTest.java new file mode 100644 index 0000000..8ff7583 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/GameStringsTest.java @@ -0,0 +1,43 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; + +import org.junit.Test; + +public final class GameStringsTest { + @Test + public void missingOrUnknownLanguageSafelyFallsBackToEnglish() { + assertEquals(GameLanguage.EN, GameLanguage.fromCode(null)); + assertEquals(GameLanguage.EN, GameLanguage.fromCode("de")); + + GameStrings strings = GameStrings.forLanguage(GameLanguage.fromCode("de")); + assertEquals("Settings", strings.settingsButton()); + assertEquals("The Volunteer", strings.levelTitle(1)); + } + + @Test + public void russianPackLocalizesTheCompletePlayerFacingContract() { + GameStrings english = GameStrings.forLanguage(GameLanguage.EN); + GameStrings russian = GameStrings.forLanguage(GameLanguage.RU); + + assertEquals("Настройки", russian.settingsButton()); + assertEquals("Размер управления", russian.controlSizeSetting()); + assertEquals("Русский", russian.languageName()); + assertEquals("Рыцарь-доброволец", russian.levelTitle(1)); + assertEquals("Последнее шествие", russian.levelTitle(10)); + 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++) { + assertFalse(russian.levelTitle(level).trim().isEmpty()); + assertFalse(russian.levelHint(level).trim().isEmpty()); + assertNotEquals(english.levelTitle(level), russian.levelTitle(level)); + assertNotEquals(english.levelHint(level), russian.levelHint(level)); + } + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java b/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java index 88eaa36..9c6d456 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java @@ -3,6 +3,7 @@ package com.projectsacrifice.game; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import org.junit.Test; @@ -68,6 +69,41 @@ public final class LevelPlaytestBotTest { assertTrue(result.diagnostic(), result.diagnostic().contains("allDoorsOpen=false")); } + @Test + public void slowRenderingUsesSimulationElapsedBudgetsInsteadOfHostTime() { + SlowRenderingDriver driver = new SlowRenderingDriver(true); + LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario + .builder(1, "Slow rendering") + .step("Reach simulated checkpoint", 200L, LevelPlaytestBot.Input.right(), + GameView.AutomationSnapshot::completed) + .build(); + + LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 500L); + + assertTrue(result.diagnostic(), result.completed()); + assertTrue("The host clock must be allowed to outrun simulated gameplay", + driver.wallMillis > 500L); + assertEquals(96L, driver.simulationMillis); + } + + @Test + public void frozenSimulationStopsAtWallClockWatchdogWithDiagnostic() { + SlowRenderingDriver driver = new SlowRenderingDriver(false); + LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario + .builder(1, "Frozen rendering") + .step("Wait for simulation", 1_000L, LevelPlaytestBot.Input.right(), + GameView.AutomationSnapshot::completed) + .build(); + + LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 5_000L); + + assertFalse(result.completed()); + assertEquals("Wait for simulation", result.lastStep()); + assertTrue(result.diagnostic(), result.diagnostic().contains("wall-clock watchdog")); + assertTrue(result.diagnostic(), result.diagnostic().contains("simulationElapsed=0")); + assertTrue(result.diagnostic(), result.diagnostic().contains("wallElapsed=120000")); + } + @Test public void levelFiveUsesTheGenericNamedScenario() { LevelFiveDriver driver = new LevelFiveDriver(); @@ -82,6 +118,17 @@ public final class LevelPlaytestBotTest { assertTrue(result.doorOpened()); } + @Test + public void levelFiveDelayedDecisionStillClearsTheSawWithOneVolunteer() { + LevelFiveDriver driver = new LevelFiveDriver(true); + + LevelPlaytestBot.Result result = new LevelPlaytestBot().proveLevelFive(driver, 25_000L); + + assertTrue(result.diagnostic(), result.completed()); + assertEquals(1, result.deaths()); + assertEquals(1, result.corpseCount()); + } + @Test public void automationSnapshotDefaultsToNoMovingPlatform() { GameView.AutomationSnapshot snapshot = new GameView.AutomationSnapshot( @@ -90,6 +137,41 @@ public final class LevelPlaytestBotTest { assertTrue(Float.isNaN(snapshot.firstMovingPlatformY())); assertFalse(snapshot.playerOnMovingPlatform()); + assertFalse(snapshot.firstMovingPlatformMovingDown()); + } + + @Test + public void groundedApproachWaitsForAliveGroundedPlayerAtCheckpoint() { + LevelPlaytestBot.SnapshotCondition checkpoint = + LevelPlaytestBot.groundedAliveAtOrBeyond(280f); + + assertFalse(checkpoint.reached(snapshot(true, true, 279f, 1))); + assertFalse(checkpoint.reached(snapshot(false, true, 280f, 1))); + assertFalse(checkpoint.reached(snapshot(true, false, 280f, 1))); + assertTrue(checkpoint.reached(snapshot(true, true, 280f, 1))); + } + + @Test + public void expectedDeathGuardFailsBeforeAnAccidentalRetry() { + LevelPlaytestBot.SnapshotCondition guarded = LevelPlaytestBot.whileDeathsAtMost( + 1, snapshot -> snapshot.playerX() >= 280f); + + assertTrue(guarded.reached(snapshot(true, false, 320f, 1))); + try { + guarded.reached(snapshot(false, false, 470f, 2)); + fail("An accidental second death must fail the route immediately"); + } catch (AssertionError expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains("expected at most 1")); + assertTrue(expected.getMessage(), expected.getMessage().contains("observed 2")); + } + } + + private static GameView.AutomationSnapshot snapshot( + boolean alive, boolean onGround, float x, int deaths) { + return new GameView.AutomationSnapshot( + false, alive, false, false, false, + 10, 0L, x, 760f, deaths, deaths, + Float.NaN, Float.NaN, false, false, Float.NaN, onGround); } private static final class ScriptedDriver implements LevelPlaytestBot.Driver { @@ -160,7 +242,52 @@ public final class LevelPlaytestBotTest { } } + private static final class SlowRenderingDriver implements LevelPlaytestBot.Driver { + private final boolean advancesSimulation; + private long wallMillis; + private long simulationMillis; + private boolean movingRight; + private boolean completed; + + SlowRenderingDriver(boolean advancesSimulation) { + this.advancesSimulation = advancesSimulation; + } + + @Override + public void startLevel(int oneBasedLevelNumber) { + wallMillis = 0L; + simulationMillis = 0L; + movingRight = false; + completed = false; + } + + @Override + public void setInput(boolean moveLeft, boolean moveRight, boolean jump) { + movingRight = moveRight; + } + + @Override + public GameView.AutomationSnapshot snapshot() { + return new GameView.AutomationSnapshot( + completed, true, false, false, movingRight, + 1, simulationMillis, 130f, 760f, 0, 0); + } + + @Override + public void waitForNextDecision(long millis) { + wallMillis += advancesSimulation ? 1_000L : 60_000L; + if (advancesSimulation) simulationMillis += millis; + if (movingRight && simulationMillis >= 96L) completed = true; + } + + @Override + public long currentTimeMillis() { + return wallMillis; + } + } + private static final class LevelFiveDriver implements LevelPlaytestBot.Driver { + private final boolean overshootApproach; private int startedLevel; private long nowMillis; private boolean moveRight; @@ -171,6 +298,16 @@ public final class LevelPlaytestBotTest { private int deaths; private int corpses; private float x = 130f; + private float y = 760f; + private boolean sawTakeoffObserved; + + LevelFiveDriver() { + this(false); + } + + LevelFiveDriver(boolean overshootApproach) { + this.overshootApproach = overshootApproach; + } @Override public void startLevel(int oneBasedLevelNumber) { @@ -187,7 +324,7 @@ public final class LevelPlaytestBotTest { public GameView.AutomationSnapshot snapshot() { return new GameView.AutomationSnapshot( completed, alive, doorOpen, false, moveRight || jump, - startedLevel, nowMillis, x, 760f, deaths, corpses); + startedLevel, nowMillis, x, y, deaths, corpses); } @Override @@ -196,6 +333,7 @@ public final class LevelPlaytestBotTest { if (!alive) { alive = true; x = 130f; + y = 760f; } else if (deaths == 0 && moveRight && !jump) { deaths = 1; corpses = 1; @@ -203,11 +341,24 @@ public final class LevelPlaytestBotTest { doorOpen = true; x = 570f; } else if (moveRight && jump) { + if (x < 750f) { + sawTakeoffObserved = true; + y = 690f; + } x += 60f; } else if (moveRight && x < 390f) { - x = 400f; + x = overshootApproach ? 440f : 400f; } else if (moveRight && x < 750f) { - x = 760f; + if (overshootApproach && !sawTakeoffObserved) { + deaths++; + corpses++; + alive = false; + x = 510f; + y = 780f; + } else { + x = 760f; + y = 760f; + } } else if (moveRight && x < 1_390f) { x = 1_500f; } else if (moveRight) { diff --git a/app/src/test/java/com/projectsacrifice/game/MovingPlatformLaunchPolicyTest.java b/app/src/test/java/com/projectsacrifice/game/MovingPlatformLaunchPolicyTest.java new file mode 100644 index 0000000..c4deac6 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/MovingPlatformLaunchPolicyTest.java @@ -0,0 +1,40 @@ +package com.projectsacrifice.game; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class MovingPlatformLaunchPolicyTest { + @Test + public void boardingWaitsForThePoweredOutboundPhaseNearTheLeftFloor() { + assertFalse(MovingPlatformLaunchPolicy.readyToBoard( + snapshot(1_000f, false, true, true, false), 1_050f)); + assertFalse(MovingPlatformLaunchPolicy.readyToBoard( + snapshot(1_000f, false, true, false, true), 1_050f)); + assertFalse(MovingPlatformLaunchPolicy.readyToBoard( + snapshot(1_100f, false, true, true, true), 1_050f)); + assertTrue(MovingPlatformLaunchPolicy.readyToBoard( + snapshot(1_000f, false, true, true, true), 1_050f)); + } + + @Test + public void launchRequiresStablePoweredSupportInTheLaunchZone() { + assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(910f, true, true, true, true), 1_150f)); + assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, false, true, true, true), 1_150f)); + assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, false, true, true), 1_150f)); + assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, false, true), 1_150f)); + assertTrue(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, true, true), 1_150f)); + } + + private static GameView.AutomationSnapshot snapshot( + float platformX, boolean supported, boolean grounded, boolean powered, + boolean movingRight + ) { + return new GameView.AutomationSnapshot( + false, true, true, false, false, 8, + 4_000L, 1_100f, 670f, 0, 0, + platformX, 770f, supported, powered, movingRight, Float.NaN, grounded + ); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/OnboardingFlowTest.java b/app/src/test/java/com/projectsacrifice/game/OnboardingFlowTest.java new file mode 100644 index 0000000..23e6241 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/OnboardingFlowTest.java @@ -0,0 +1,38 @@ +package com.projectsacrifice.game; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class OnboardingFlowTest { + @Test + public void firstRunExplainsControlsAndCorpsesThenStaysDismissed() { + OnboardingFlow flow = OnboardingFlow.firstRun(false); + + assertTrue(flow.isVisible()); + assertEquals(OnboardingFlow.Step.MOVEMENT, flow.step()); + + flow.next(); + assertEquals(OnboardingFlow.Step.MULTITOUCH_JUMP, flow.step()); + + flow.next(); + assertEquals(OnboardingFlow.Step.CORPSES_ARE_TOOLS, flow.step()); + + flow.next(); + assertFalse(flow.isVisible()); + assertTrue(flow.isComplete()); + assertFalse(OnboardingFlow.firstRun(flow.isComplete()).isVisible()); + } + + @Test + public void firstRunCanBeSkippedImmediately() { + OnboardingFlow flow = OnboardingFlow.firstRun(false); + + flow.skip(); + + assertFalse(flow.isVisible()); + assertTrue(flow.isComplete()); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/PlaytestSimulationBudgetTest.java b/app/src/test/java/com/projectsacrifice/game/PlaytestSimulationBudgetTest.java new file mode 100644 index 0000000..977ba8f --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/PlaytestSimulationBudgetTest.java @@ -0,0 +1,21 @@ +package com.projectsacrifice.game; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class PlaytestSimulationBudgetTest { + @Test + public void slowRenderingUsesSimulationBudgetWithASeparateWallWatchdog() { + PlaytestSimulationBudget budget = PlaytestSimulationBudget.start(0L, 0L, 500L); + + assertFalse("Host time may outrun active simulation", budget.exhausted(96L, 1_000L)); + assertTrue("Simulation budget remains authoritative", budget.exhausted(500L, 5_000L)); + + PlaytestSimulationBudget frozen = PlaytestSimulationBudget.start(0L, 0L, 5_000L); + assertFalse(frozen.exhausted(0L, 119_999L)); + assertTrue("Frozen rendering stops at the same watchdog as the generic bot", + frozen.exhausted(0L, 120_000L)); + } +} diff --git a/docs/CURRENT_ROADMAP.md b/docs/CURRENT_ROADMAP.md index 9c00773..cbb15ed 100644 --- a/docs/CURRENT_ROADMAP.md +++ b/docs/CURRENT_ROADMAP.md @@ -6,7 +6,7 @@ ## 1. Текущее состояние -Инженерная часть этапа 2 завершена; этап 3 начат с настроек мобильного управления: +Инженерная часть этапов 2 и 3 завершена: - реализованы 10 последовательных уровней; - работают движение, прыжок и мобильное multitouch-управление; @@ -19,6 +19,12 @@ - тело, доставленное пилой на `PRESSURE` уровня 10, фиксируется после оседания и больше не соскальзывает с плиты; - restart из промежуточного состояния уровня 10 очищает тела, сигналы, таймер и удерживаемый ввод; - Settings screen сохраняет размер и opacity touch-controls и режим вибрации; три form-factor контракта и защита от повторного held-jump покрыты device-тестами; +- добавлены сохраняемые Standard/Large text и high-contrast режимы, видимый статус сигналов/дверей/respawn и локализованные content descriptions; +- короткое трёхшаговое обучение не блокирует gameplay и после Skip/Done не показывается повторно; +- экран результата разделяет текущий и лучший результаты, показывает targets, следующую цель и рабочие Level select/Replay/Next level переходы; +- весь Canvas UI, десять названий и подсказок доступны на русском и английском; язык хранится отдельно и не меняет progress/control settings; +- Stage 3 focused matrix: 14/14 методов без retries; UI contracts GREEN на 20:9, 16:9 и логическом 16:10 tablet viewport; +- final Stage 3 Android 16 matrix: `OK (30 tests)` без filters/retries за `1,409.366 s`; все intended routes 1–10, recovery/stress, lifecycle, restart и UI/persistence contracts GREEN; - final integrated Android 16 matrix: 16/16 методов, 36 игровых выполнений, lifecycle и restart; wall time `9:32.496`; - финальный debug APK проверен на Android 16: установка, холодный запуск, ZIP-структура и v1/v2-подписи корректны. @@ -176,10 +182,15 @@ Performance-оговорка: исторический критерий этап 8. холодный запуск и проверка crash-буфера; 9. ручной smoke-тест уровня 1 и одного сложного комбинированного уровня. -## 5. Завершение этапа 2 и следующая итерация +## 5. Завершение этапов 2–3 и следующая итерация Инженерный scope этапа 2 закрыт: уровни 1–10 проходят intended route по три раза, уровень 8 имеет restart-free recovery, уровень 10 использует все три тела и сохраняет тело на lift-плите, а restart возвращает чистое состояние. Передаваемые результаты — обновлённый проект, проверенный APK и [`STAGE2_QA_REPORT.md`](STAGE2_QA_REPORT.md). -Этап 3 начат задачей `goon-game-controls-settings`: добавлены сохраняемые пресеты размера/opacity controls и вибрации, фактическое расширение touch target и защита от повторного прыжка при удержании. Проверки выполнены на 20:9, логическом 16:9 и tablet-sized экране; подробности находятся в [`STAGE3_CONTROLS_QA.md`](STAGE3_CONTROLS_QA.md). Следующие slices этапа 3 — accessibility, обучение/result screen и RU/EN локализация. +Этап 3 завершён: к сохраняемым пресетам управления добавлены accessibility, +неблокирующее обучение, полный result flow и RU/EN локализация. Совместный +device-gate проверяет сохранность progress/settings, а UI contracts выполнены на +20:9, 16:9 и tablet-sized экране. Подробный итог находится в +[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md). Следующая инженерная итерация — этап +4, перенос описаний уровней в валидируемый формат и инструменты контента. Параллельно внешний QA должен выполнить `goon-game-human-playtest`: по две ручные сессии каждого уровня без подсказок разработчика. До получения этого протокола milestone Vertical Slice нельзя называть принятым внешними тестировщиками. diff --git a/docs/STAGE3_ACCESSIBILITY_QA.md b/docs/STAGE3_ACCESSIBILITY_QA.md new file mode 100644 index 0000000..1a2edd1 --- /dev/null +++ b/docs/STAGE3_ACCESSIBILITY_QA.md @@ -0,0 +1,62 @@ +# Stage 3 accessibility QA + +Дата: 18 августа 2026 года. Slice: масштаб текста, high contrast, non-audio state +и семантика Canvas-экранов. + +## Реализованный контракт + +- Settings содержит сохраняемые пресеты `Text size: Standard/Large` + (`1.0×/1.25×`) и `High contrast: Off/On`; +- настройки хранятся отдельно от control settings и progress, поэтому миграция не + меняет существующие сохранения; +- оба общих Canvas text helper применяют выбранный масштаб ко всему тексту; + viewport и hit mapping остаются неизменными, а фиксированные HUD/card/button + области обрезают переполнение внутри своих границ; +- high contrast использует чёрный фон, непрозрачные кнопки, белые контуры и + бело-жёлтый текст; активные механизмы не различаются только цветом; +- `ON/OFF`, `UP/DOWN`, `OPEN/LOCKED` и постоянная строка + `Signals · Doors · Volunteer` дают видимое представление критического состояния + при выключенной вибрации; +- content description меняется для level select, settings, playing, pause и + complete; системный Back сохраняет прежний детерминированный контракт; +- read-only automation surface: `textScaleForAutomation()`, + `highContrastForAutomation()`, `screenDescriptionForAutomation()`, + `criticalVisibleStatusForAutomation()` и `safeViewportForAutomation()`. + +## TDD evidence + +- RED: `AccessibilitySettings` отсутствовал, focused JVM-test завершался шестью + `cannot find symbol`; +- GREEN: `Standard/Large`, `Off/On` и безопасное восстановление persisted index; +- RED → GREEN отдельными циклами: `highContrastEnabled`, `restore`, + `canvasTextSize`, `AccessibilityStatus.describe` и + `AccessibilityScreenDescription`; +- RED device compile: отсутствовали persisted/public Settings surface, + screen transition и safe-viewport hooks; +- GREEN device compile: пять focused Android acceptance методов компилируются и + проверяют recreation, реальные Settings taps, screen/Back semantics, + неизменный safe viewport и textual state при `Vibration: Off`. + +## Проверки этого isolated slice + +- focused accessibility JVM tests — GREEN; +- все JVM tests — обязательный gate перед коммитом; +- `lintDebug`, `assembleDebug`, `compileDebugAndroidTestJavaWithJavac` — обязательный + gate перед коммитом; +- device execution намеренно оставлен финальной интеграционной ветке: этот stream + не запускает и не меняет общий emulator. + +## Интеграционные замечания + +- `GameView` одновременно изменяется onboarding/result и localization slices; + при merge нужно сохранить единые text helpers и вызывать refresh semantics после + всех новых screen transitions; +- localized строки должны заменить английский текст и в Canvas, и в content + descriptions одним источником, не удаляя read-only acceptance hooks; +- minSdk 23, control settings, progress format и Stage 2 physics/levels не менялись. + +## Финальная интеграция + +Все пять device-методов выполнены в составе unfiltered Android 16 gate: +`OK (30 tests)`, `1,409.366 s`. Итоговая сборка и form-factor evidence описаны в +[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md). diff --git a/docs/STAGE3_LOCALIZATION_QA.md b/docs/STAGE3_LOCALIZATION_QA.md new file mode 100644 index 0000000..a635efc --- /dev/null +++ b/docs/STAGE3_LOCALIZATION_QA.md @@ -0,0 +1,65 @@ +# Stage 3 RU/EN localization QA + +Date: 2026-08-18 +Scope: persistent, explicit English/Russian language selection for the Canvas UI. + +## Delivered contract + +- English is the default for missing, null, or unknown stored language codes. +- The Settings screen exposes a dedicated English/Russian switch without moving the + existing control-size, opacity, vibration, or Back touch targets. +- The selected language is stored separately from progress and control preferences + and is restored when `MainActivity` is recreated. +- `GameStrings` owns all player-facing copy: level select, settings, HUD, respawn, + pause, completion, buttons, statistics, ten level titles and hints. +- Result-flow and onboarding copy has typed RU/EN hooks ready for the other Stage 3 + slices; new Canvas code does not need language conditionals. +- Long localized hints, card titles, and best-result summaries are fitted to their + existing Canvas bounds. Gameplay geometry and Android `minSdk 23` are unchanged. +- Automation exposes current/set/toggle language and a read-only snapshot of the + localized copy rendered by the current screen. + +## TDD evidence + +1. RED: the fallback test failed compilation with seven missing `GameLanguage` / + `GameStrings` symbols. GREEN: English default and safe unknown/null fallback. +2. RED: the complete Russian-pack test failed compilation with ten missing copy + methods. GREEN: complete RU pack, ten titles/hints, statistics, plural forms, + result hooks, and onboarding hooks. +3. RED: the persistence device test failed compilation with four missing GameView + language hooks. GREEN: Settings toggle plus a dedicated preference store. +4. RED: the integrated acceptance extension failed compilation with three missing + setter/snapshot hooks. GREEN: explicit language setter and current-screen copy + snapshot. + +## Automated checks + +| Gate | Result | +| --- | --- | +| `testDebugUnitTest` | GREEN, 22/22 JVM tests | +| `lintDebug` | GREEN | +| `assembleDebug` | GREEN | +| `compileDebugAndroidTestJavaWithJavac` | GREEN, including `GameLanguagePersistenceTest` | +| Canvas raw-copy audit | GREEN; only language-neutral `II` and numeric formats remain in draw calls | + +`GameLanguagePersistenceTest` is compiled here but intentionally not executed in +this isolated stream because emulator ownership belongs to final Stage 3 integration. +It performs real Settings taps and verifies Russian visible-copy snapshot, Activity +recreation, progress, control size, opacity, and vibration preservation. + +## Integration notes + +- `GameView.java` will conflict with accessibility/onboarding/result branches. Keep + their UI behavior and route every new visible string through the existing + `GameStrings` result/onboarding hooks. +- `ControlSettings.java` is not modified; localized preset labels are derived from + its stable package-visible preset indexes. +- `LevelCatalog.java` and level geometry are not modified. Canvas rendering now uses + `GameStrings.levelTitle/levelHint`; the existing English fields remain compatible + with gameplay tests and older callers. + +## Final integration + +`GameLanguagePersistenceTest` and the combined Stage 3 RU/EN acceptance tests passed +inside the unfiltered Android 16 gate: `OK (30 tests)`, `1,409.366 s`. See +[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md) for the release evidence. diff --git a/docs/STAGE3_ONBOARDING_RESULT_QA.md b/docs/STAGE3_ONBOARDING_RESULT_QA.md new file mode 100644 index 0000000..604abe1 --- /dev/null +++ b/docs/STAGE3_ONBOARDING_RESULT_QA.md @@ -0,0 +1,64 @@ +# Stage 3 onboarding and result QA + +Дата: 18 августа 2026 года. Базовая сборка: `0.5.0-stage3-controls-debug` +(`minSdk=23`). Этот документ описывает независимый slice обучения и результата; +версия финального Stage 3 APK будет назначена после интеграции всех веток. + +## Реализовано + +- при первом входе в уровень показывается компактная карточка из трёх подсказок: + движение, одновременные движение и прыжок двумя пальцами, использование тел; +- карточка не останавливает физику и не отключает экранное управление; +- доступны `Skip`, пошаговый `Next` и финальный `Done`; +- закрытие сохраняется в отдельном versioned storage + `project_sacrifice_onboarding_v1` и не изменяет progress/control settings; +- результат разделяет показатели текущего прохождения и сохранённые лучшие время + и число добровольцев; +- результат показывает оба target и выбирает понятную следующую цель: улучшить + число добровольцев, время, перейти на следующий уровень либо улучшить personal best; +- `Level select`, `Replay` и `Next level` используют общий navigation contract и + не очищают прогресс или настройки управления. + +## TDD evidence + +- `OnboardingFlowTest`: RED — `cannot find symbol: class OnboardingFlow`; GREEN — 2/2; +- `CompletionResultTest`: RED — `cannot find symbol: class CompletionResult`; GREEN — 2/2; +- `OnboardingStoreTest`: RED — `cannot find symbol: class OnboardingStore`; после + реализации AndroidTest compile GREEN; +- `OnboardingAndResultFlowTest`: RED — отсутствовали публичные GameView contracts + обучения/result navigation; после интеграции AndroidTest compile GREEN. + +## Выполненные проверки + +```text +./gradlew testDebugUnitTest lintDebug assembleDebug compileDebugAndroidTestJavaWithJavac +BUILD SUCCESSFUL +``` + +- JVM: 24/24, failures=0, errors=0; +- `lintDebug`: GREEN; +- `assembleDebug`: GREEN; +- все Android instrumentation sources: compile GREEN; +- `git diff --check`: GREEN. + +По ограничению stream-задачи emulator/connected tests в этой ветке не запускались. +После интеграции необходимо выполнить `OnboardingStoreTest` и +`OnboardingAndResultFlowTest` на Android 16 вместе с полным Stage 2 regression gate. + +## Приёмка на устройстве после интеграции + +1. Очистить данные приложения, открыть уровень 1 и убедиться, что карточка не + мешает одновременно удерживать направление и нажимать Jump. +2. Проверить отдельно `Skip` и полный путь `Next` → `Next` → `Done`, затем cold + launch: обучение не должно появиться повторно. +3. Завершить уровень хуже сохранённого результата: `This run` не должен подменять + `Saved best`. +4. По очереди проверить `Level select`, `Replay`, `Next level`; завершённость + уровня, unlocked level и control preset должны сохраниться. + +## Финальная интеграция + +`OnboardingStoreTest`, оба `OnboardingAndResultFlowTest` и объединённый RU/EN +acceptance прошли в составе unfiltered Android 16 gate: `OK (30 tests)`, +`1,409.366 s`. Релизная версия назначена `0.6.0-stage3-complete`; общий отчёт: +[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md). diff --git a/docs/STAGE3_QA_REPORT.md b/docs/STAGE3_QA_REPORT.md new file mode 100644 index 0000000..2805c51 --- /dev/null +++ b/docs/STAGE3_QA_REPORT.md @@ -0,0 +1,93 @@ +# Stage 3 QA report + +Дата: 18 августа 2026 года. + +Версия: `0.6.0-stage3-complete` (`versionCode=7`). + +## Результат + +Инженерный scope этапа 3 завершён. В общую сборку интегрированы: + +- сохраняемые размер и прозрачность touch-controls и режим вибрации; +- Standard/Large text, high contrast, видимые состояния сигналов, дверей и respawn; +- локализованные accessibility descriptions; +- полная RU/EN локализация Canvas UI, десяти названий и подсказок уровней; +- неблокирующее трёхшаговое обучение с постоянным Skip/Done; +- result screen с текущим и лучшим результатом, targets, следующей целью и + рабочими переходами Levels/Replay/Next; +- совместная сохранность языка, accessibility, controls и прогресса после recreate; +- state-driven боты уровней 1–10, restart-free recovery уровня 8 и проверка + неподвижного тела на lift-плите уровня 10. + +## Автоматические проверки + +Финальный clean gate: + +```text +./gradlew clean testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest +BUILD SUCCESSFUL +66 actionable tasks +42/42 JVM tests +``` + +Финальный unfiltered device gate выполнен на холодно запущенном Android 16 AVD, +физический размер `1080x2400` (20:9), после установки точных APK clean-сборки и +`pm clear com.projectsacrifice.game.debug`: + +```text +AndroidJUnitRunner +Tests run: 30 +Time: 1,409.366 s +OK (30 tests) +``` + +Runner запускался без `-e class`, retries и повторного запуска отдельных методов. +Матрица включает все intended routes уровней 1–10 по три раза, дополнительные +stressed/recovery маршруты уровня 8, throttled mastery и sliding-body regression +уровня 10, lifecycle, restart и все Stage 3 persistence/UI contracts. + +До финального объединения UI contracts также прошли на логических viewport: + +- 16:9 safe viewport — GREEN, `66.208 s`; +- 16:10 tablet-sized `2560x1600` landscape — GREEN, `167.823 s`; +- focused Stage 3 matrix — 14/14 GREEN, `576.355 s`. + +Последующие изменения касались automation/read-only snapshot и не меняли UI +геометрию; финальный 20:9 gate повторно проверил весь интегрированный интерфейс. + +## RED → GREEN evidence + +Первый полный 30-тестовый прогон был завершён полностью и дал 27/30 GREEN. Три +нагрузочные границы были исправлены без изменения физики уровней или retry: + +1. Level 3: takeoff начинался на несколько пикселей поздно (`x=505.67633`, + `y=664.41797`, лишняя смерть). State-driven точка старта получила запас и + повторно прошла 3/3 внутри полного load-order. +2. Level 6: координатный checkpoint не доказывал опору на frozen block; добавлен + обязательный `playerOnGround`, после чего landing route прошёл 3/3. +3. Level 10: бот мог прыгнуть на лифт во время его восходящей фазы и упасть в PIT + (`x=1942.7063`, deaths=4). Snapshot расширен read-only направлением движения; + посадка разрешается на нисходящей фазе, normal 3/3 и throttled route GREEN. + +Level 8 отдельно получил исходящую фазу горизонтальной платформы, безопасное +позиционирование под recovery relay и перед финальным прыжком. Финальный suite +подтвердил stressed 1/1, intended 3/3 и recovery 3/3 без смерти/restart. + +## Проверка APK + +Финальный `app-debug.apk` проверен как самостоятельный устанавливаемый артефакт: + +- ZIP/APK-структура: `unzip -t` без ошибок; +- package: `com.projectsacrifice.game.debug`; +- версия: `versionCode=7`, `versionName=0.6.0-stage3-complete-debug`; +- `minSdkVersion=23`, `targetSdkVersion=34`; +- подпись: `apksigner verify` GREEN, схемы v1 и v2; +- SHA-256: `7be25ea96cf0de7ea1930335b3570bd9def93cdb48029e24e4c9c14078e4195d`; +- clean install и cold launch на Android 16: `Status: ok`, `LaunchState: COLD`, + `TotalTime: 1401 ms`; crash-буфер пуст. + +## Границы приёмки + +Инженерный Stage 3 gate закрыт. Отдельная задача ручного внешнего playtest остаётся +открытой: реальные тестировщики должны пройти каждый уровень без подсказок +разработчика. Этот внешний протокол не подменяется автоматическими ботами.