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..d5635dc --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/AccessibilityPersistenceTest.java @@ -0,0 +1,163 @@ +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.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.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/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/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/GameStrings.java b/app/src/main/java/com/projectsacrifice/game/GameStrings.java index 1791e85..f380093 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameStrings.java +++ b/app/src/main/java/com/projectsacrifice/game/GameStrings.java @@ -104,6 +104,16 @@ public final class GameStrings { 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) { @@ -224,6 +234,79 @@ public final class GameStrings { 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)]; } diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index 5919eaa..b514c8f 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -14,13 +14,16 @@ 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.HashSet; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; /** * A dependency-free 2D puzzle-platformer renderer and deterministic physics loop. @@ -164,6 +167,7 @@ public final class GameView extends View implements Runnable { 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<>(); @@ -194,6 +198,8 @@ public final class GameView extends View implements Runnable { private CompletionResult completionResult; private GameLanguage language; private GameStrings strings; + private AccessibilitySettings accessibilitySettings; + private String lastAccessibilityStateKey = ""; public GameView(Context context) { super(context); @@ -205,12 +211,14 @@ public final class GameView extends View implements Runnable { 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(strings.appContentDescription()); + refreshAccessibilityDescription(false); running = true; postOnAnimation(this); } @@ -239,12 +247,14 @@ 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) { @@ -258,6 +268,7 @@ public final class GameView extends View implements Runnable { } pointers.clear(); deriveControls(); + refreshAccessibilityDescription(true); return true; } @@ -281,6 +292,7 @@ public final class GameView extends View implements Runnable { for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item)); spawnPlayer(); lastFrameNanos = System.nanoTime(); + refreshAccessibilityDescription(true); } private void restartLevel() { @@ -320,6 +332,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); @@ -656,6 +670,7 @@ public final class GameView extends View implements Runnable { pointers.clear(); deriveControls(); emitHaptic(HapticFeedbackConstants.LONG_PRESS); + refreshAccessibilityDescription(true); } private RectF playerRect() { @@ -673,9 +688,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); @@ -714,6 +727,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); @@ -745,10 +764,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); @@ -771,6 +799,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); @@ -785,6 +814,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); } } } @@ -877,6 +907,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); } } @@ -949,13 +980,24 @@ public final class GameView extends View implements Runnable { private void drawHud(Canvas canvas) { paint.setColor(Color.argb(178, 15, 20, 34)); canvas.drawRoundRect(new RectF(28, 26, 940, 138), 24, 24, paint); + canvas.save(); + canvas.clipRect(40, 30, 925, 135); drawText(canvas, String.format(Locale.US, "%02d %s", level.number, strings.levelTitle(level.number)), 56, 72, 34, Color.rgb(248, 220, 151), false); drawFittedText(canvas, strings.levelHint(level.number), 56, 116, 25, 16, 850, Color.rgb(231, 231, 235), false); + canvas.restore(); + canvas.save(); + canvas.clipRect(990, 28, 1590, 130); drawText(canvas, strings.hudStats(deaths, (long) (levelElapsed * 1000)), 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), strings.restart(), false); drawHudButton(canvas, new RectF(1790, 26, 1892, 112), "II", false); @@ -973,11 +1015,15 @@ public final class GameView extends View implements Runnable { 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(), @@ -1003,12 +1049,17 @@ 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) : strings.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); drawCenteredFittedText(canvas, strings.levelTitle(item.number), card.centerX(), @@ -1024,6 +1075,7 @@ public final class GameView extends View implements Runnable { drawCenteredText(canvas, strings.tapToEnter(), card.centerX(), top + 190, 22, Color.rgb(202, 204, 214)); } + canvas.restore(); } drawHudButton(canvas, new RectF(1560, 70, 1850, 160), strings.settingsButton(), false); drawCenteredText(canvas, strings.levelSelectFooter(), VIEW_WIDTH * 0.5f, 980, 24, @@ -1044,18 +1096,47 @@ public final class GameView extends View implements Runnable { 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 drawSettingRow(Canvas canvas, RectF box, String label, String value) { - paint.setColor(Color.argb(220, 38, 45, 65)); + 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(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 + 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(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 + 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) { @@ -1176,12 +1257,19 @@ public final class GameView extends View implements Runnable { } 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) { @@ -1191,8 +1279,8 @@ 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); @@ -1225,8 +1313,8 @@ public final class GameView extends View implements Runnable { 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); @@ -1236,6 +1324,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())); @@ -1287,6 +1385,7 @@ public final class GameView extends View implements Runnable { userPaused = true; pointers.clear(); deriveControls(); + refreshAccessibilityDescription(true); return true; } } @@ -1334,6 +1433,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; @@ -1384,6 +1484,7 @@ public final class GameView extends View implements Runnable { screen = Screen.LEVELS; pointers.clear(); deriveControls(); + refreshAccessibilityDescription(true); } private void replayFromResult() { @@ -1402,10 +1503,17 @@ public final class GameView extends View implements Runnable { userPaused = false; screen = Screen.LEVELS; } + refreshAccessibilityDescription(true); } private void handleSettingsTap(float x, float y) { - if (contains(1580, 70, 1850, 160, 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)) { @@ -1420,6 +1528,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); } } @@ -1431,6 +1540,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; } @@ -1581,6 +1696,11 @@ public final class GameView extends View implements Runnable { 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, @@ -1623,18 +1743,145 @@ public final class GameView extends View implements Runnable { language = updated == null ? GameLanguage.EN : updated; strings = GameStrings.forLanguage(language); languageStore.save(language); - setContentDescription(strings.appContentDescription()); + 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); + refreshAccessibilityDescription(false); + invalidate(); + } + + private String criticalStatusText() { + if (level == null) return AccessibilityStatus.describe(strings, 0, 0, 0, 0, true); + Set signalIds = new HashSet<>(); + int activeSignals = 0; + for (LevelDefinition.Switch item : level.switches) signalIds.add(item.signalId); + for (String signalId : signalIds) if (signalActive(signalId)) activeSignals++; + int openDoors = 0; + for (LevelDefinition.Door door : level.doors) if (doorOpen(door)) openDoors++; + return AccessibilityStatus.describe(strings, activeSignals, signalIds.size(), openDoors, + level.doors.size(), player.alive); + } + + 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 String accessibilityStateKey() { + return screen + "|" + userPaused + "|" + deaths + "|" + criticalStatusText() + + "|" + controlSettings.controlSizeLabel() + + "|" + controlSettings.controlOpacityLabel() + + "|" + controlSettings.hapticsEnabled() + + "|" + accessibilitySettings.textSizeLabel() + + "|" + accessibilitySettings.highContrastEnabled() + + "|" + language; + } + + private void refreshAccessibilityDescriptionIfStateChanged() { + String stateKey = accessibilityStateKey(); + if (!stateKey.equals(lastAccessibilityStateKey)) refreshAccessibilityDescription(false); + } + + private void refreshAccessibilityDescription(boolean announce) { + lastAccessibilityStateKey = accessibilityStateKey(); + 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; 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/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/docs/STAGE3_ACCESSIBILITY_QA.md b/docs/STAGE3_ACCESSIBILITY_QA.md new file mode 100644 index 0000000..52f4603 --- /dev/null +++ b/docs/STAGE3_ACCESSIBILITY_QA.md @@ -0,0 +1,56 @@ +# 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 не менялись.