Добавить доступный интерфейс этапа 3

This commit is contained in:
2026-08-18 16:29:08 +03:00
parent dcb16ef4d0
commit 21775542d5
11 changed files with 854 additions and 23 deletions
@@ -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<MainActivity> 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<MainActivity> 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<MainActivity> 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<MainActivity> 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<MainActivity> 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<MainActivity> activity, float canvasX, float canvasY) {
touch(activity, MotionEvent.ACTION_DOWN, canvasX, canvasY);
touch(activity, MotionEvent.ACTION_UP, canvasX, canvasY);
}
private static void touch(ActivityScenario<MainActivity> 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));
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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)];
}
@@ -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<PointF> pointers = new SparseArray<>();
private final Player player = new Player();
private final ArrayList<Corpse> 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<String> 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;
@@ -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"));
}
}
@@ -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);
}
}
@@ -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)
);
}
}
+56
View File
@@ -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 не менялись.