diff --git a/app/src/main/java/com/projectsacrifice/game/AccessibilityStateCache.java b/app/src/main/java/com/projectsacrifice/game/AccessibilityStateCache.java new file mode 100644 index 0000000..b58d20e --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/AccessibilityStateCache.java @@ -0,0 +1,58 @@ +package com.projectsacrifice.game; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Allocation-free critical-state comparison after a level has been configured. */ +final class AccessibilityStateCache { + private List signalIds = Collections.emptyList(); + private GameLanguage language; + private int activeSignals = Integer.MIN_VALUE; + private int openDoors = Integer.MIN_VALUE; + private int totalDoors = Integer.MIN_VALUE; + private boolean playerAlive; + private String statusText; + + void configureSignals(List sourceIds) { + ArrayList unique = new ArrayList<>(); + for (String signalId : sourceIds) { + if (!unique.contains(signalId)) unique.add(signalId); + } + signalIds = Collections.unmodifiableList(unique); + language = null; + activeSignals = Integer.MIN_VALUE; + openDoors = Integer.MIN_VALUE; + totalDoors = Integer.MIN_VALUE; + statusText = null; + } + + List signalIds() { + return signalIds; + } + + boolean update(GameStrings strings, int updatedActiveSignals, int updatedOpenDoors, + int updatedTotalDoors, boolean updatedPlayerAlive) { + GameLanguage updatedLanguage = strings.language(); + if (statusText != null + && language == updatedLanguage + && activeSignals == updatedActiveSignals + && openDoors == updatedOpenDoors + && totalDoors == updatedTotalDoors + && playerAlive == updatedPlayerAlive) { + return false; + } + language = updatedLanguage; + activeSignals = updatedActiveSignals; + openDoors = updatedOpenDoors; + totalDoors = updatedTotalDoors; + playerAlive = updatedPlayerAlive; + statusText = AccessibilityStatus.describe(strings, activeSignals, signalIds.size(), + openDoors, totalDoors, playerAlive); + return true; + } + + String statusText() { + return statusText; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index b514c8f..900edb5 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -18,12 +18,10 @@ 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. @@ -175,6 +173,8 @@ public final class GameView extends View implements Runnable { private final Map toggleSignals = new HashMap<>(); private final Map timedSignals = new HashMap<>(); private final Map previousSwitchContact = new HashMap<>(); + private final AccessibilityStateCache accessibilityStateCache = + new AccessibilityStateCache(); private Screen screen = Screen.LEVELS; private LevelDefinition level; @@ -199,7 +199,16 @@ public final class GameView extends View implements Runnable { private GameLanguage language; private GameStrings strings; private AccessibilitySettings accessibilitySettings; - private String lastAccessibilityStateKey = ""; + private String cachedLevelHeader = ""; + private String cachedLevelHint = ""; + private float cachedLevelHintSize = 25f; + private int cachedHudLevelNumber = -1; + private GameLanguage cachedHudLanguage; + private float cachedHudTextScale = Float.NaN; + private String cachedHudStats = ""; + private long cachedHudStatsSecond = Long.MIN_VALUE; + private int cachedHudStatsDeaths = Integer.MIN_VALUE; + private GameLanguage cachedHudStatsLanguage; public GameView(Context context) { super(context); @@ -289,6 +298,8 @@ public final class GameView extends View implements Runnable { timedSignals.clear(); previousSwitchContact.clear(); completionResult = null; + configureAccessibilitySignals(); + invalidateHudTextCache(); for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item)); spawnPlayer(); lastFrameNanos = System.nanoTime(); @@ -978,20 +989,19 @@ public final class GameView extends View implements Runnable { } private void drawHud(Canvas canvas) { + ensureHudTextCache(); paint.setColor(Color.argb(178, 15, 20, 34)); canvas.drawRoundRect(new RectF(28, 26, 940, 138), 24, 24, paint); 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); + drawText(canvas, cachedLevelHeader, 56, 72, 34, Color.rgb(248, 220, 151), + false); + drawText(canvas, cachedLevelHint, 56, 116, cachedLevelHintSize, + Color.rgb(231, 231, 235), false); canvas.restore(); canvas.save(); canvas.clipRect(990, 28, 1590, 130); - drawText(canvas, strings.hudStats(deaths, (long) (levelElapsed * 1000)), - 1010, 82, 31, Color.WHITE, false); + drawText(canvas, hudStatsText(), 1010, 82, 31, Color.WHITE, false); canvas.restore(); paint.setColor(accessibilitySettings.highContrastEnabled() ? Color.BLACK : Color.argb(178, 15, 20, 34)); @@ -1743,6 +1753,7 @@ public final class GameView extends View implements Runnable { language = updated == null ? GameLanguage.EN : updated; strings = GameStrings.forLanguage(language); languageStore.save(language); + invalidateHudTextCache(); refreshAccessibilityDescription(false); invalidate(); } @@ -1824,20 +1835,76 @@ public final class GameView extends View implements Runnable { private void updateAccessibilitySettings(AccessibilitySettings updated) { accessibilitySettings = updated; accessibilitySettingsStore.save(updated); + invalidateHudTextCache(); refreshAccessibilityDescription(false); invalidate(); } - private String criticalStatusText() { - if (level == null) return AccessibilityStatus.describe(strings, 0, 0, 0, 0, true); - Set signalIds = new HashSet<>(); + private void invalidateHudTextCache() { + cachedHudLevelNumber = -1; + cachedHudLanguage = null; + cachedHudTextScale = Float.NaN; + cachedHudStatsSecond = Long.MIN_VALUE; + cachedHudStatsDeaths = Integer.MIN_VALUE; + cachedHudStatsLanguage = null; + } + + private void ensureHudTextCache() { + if (level == null) return; + float textScale = accessibilitySettings.textScale(); + if (cachedHudLevelNumber == level.number + && cachedHudLanguage == language + && Float.compare(cachedHudTextScale, textScale) == 0) { + return; + } + cachedHudLevelNumber = level.number; + cachedHudLanguage = language; + cachedHudTextScale = textScale; + cachedLevelHeader = String.format(Locale.US, "%02d %s", level.number, + strings.levelTitle(level.number)); + cachedLevelHint = strings.levelHint(level.number); + cachedLevelHintSize = fittedTextSize(cachedLevelHint, 25f, 16f, 850f, false); + } + + private String hudStatsText() { + long displayedSecond = (long) (levelElapsed * 1000f) / 1000L; + if (cachedHudStatsSecond != displayedSecond + || cachedHudStatsDeaths != deaths + || cachedHudStatsLanguage != language) { + cachedHudStatsSecond = displayedSecond; + cachedHudStatsDeaths = deaths; + cachedHudStatsLanguage = language; + cachedHudStats = strings.hudStats(deaths, displayedSecond * 1000L); + } + return cachedHudStats; + } + + private void configureAccessibilitySignals() { + ArrayList signalIds = new ArrayList<>(); + if (level != null) { + for (LevelDefinition.Switch item : level.switches) signalIds.add(item.signalId); + } + accessibilityStateCache.configureSignals(signalIds); + } + + private boolean updateCriticalStatusCache() { int activeSignals = 0; - for (LevelDefinition.Switch item : level.switches) signalIds.add(item.signalId); - for (String signalId : signalIds) if (signalActive(signalId)) activeSignals++; + if (level != null) { + for (String signalId : accessibilityStateCache.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); + if (level != null) { + for (LevelDefinition.Door door : level.doors) if (doorOpen(door)) openDoors++; + } + return accessibilityStateCache.update(strings, activeSignals, openDoors, + level == null ? 0 : level.doors.size(), level == null || player.alive); + } + + private String criticalStatusText() { + if (accessibilityStateCache.statusText() == null) updateCriticalStatusCache(); + return accessibilityStateCache.statusText(); } private String currentAccessibilityDescription() { @@ -1859,23 +1926,16 @@ public final class GameView extends View implements Runnable { 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); + if (updateCriticalStatusCache()) applyAccessibilityDescription(false); } private void refreshAccessibilityDescription(boolean announce) { - lastAccessibilityStateKey = accessibilityStateKey(); + updateCriticalStatusCache(); + applyAccessibilityDescription(announce); + } + + private void applyAccessibilityDescription(boolean announce) { setContentDescription(currentAccessibilityDescription()); if (announce && isAttachedToWindow()) { sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); diff --git a/app/src/test/java/com/projectsacrifice/game/AccessibilityStateCacheTest.java b/app/src/test/java/com/projectsacrifice/game/AccessibilityStateCacheTest.java new file mode 100644 index 0000000..73de1c1 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/AccessibilityStateCacheTest.java @@ -0,0 +1,47 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertNotSame; + +import java.util.Arrays; + +import org.junit.Test; + +public final class AccessibilityStateCacheTest { + @Test + public void unchangedFramesReuseOneStatusAndOneDeduplicatedSignalSet() { + AccessibilityStateCache cache = new AccessibilityStateCache(); + cache.configureSignals(Arrays.asList("lift", "lift", "door")); + + assertEquals(Arrays.asList("lift", "door"), cache.signalIds()); + assertTrue(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 0, 1, true)); + String first = cache.statusText(); + + for (int frame = 0; frame < 600; frame++) { + assertFalse(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 0, 1, true)); + assertSame(first, cache.statusText()); + } + } + + @Test + public void criticalStateAndLanguageChangesInvalidateExactlyOnce() { + AccessibilityStateCache cache = new AccessibilityStateCache(); + cache.configureSignals(Arrays.asList("plate")); + cache.update(GameStrings.forLanguage(GameLanguage.EN), 0, 0, 1, true); + String initial = cache.statusText(); + + assertTrue(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 1, 1, false)); + String changed = cache.statusText(); + assertNotSame(initial, changed); + assertTrue(changed.contains("Signals 1/1")); + assertTrue(changed.contains("Volunteer respawning")); + assertFalse(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 1, 1, false)); + + assertTrue(cache.update(GameStrings.forLanguage(GameLanguage.RU), 1, 1, 1, false)); + assertTrue(cache.statusText().contains("Сигналы 1/1")); + assertFalse(cache.update(GameStrings.forLanguage(GameLanguage.RU), 1, 1, 1, false)); + } +}