Убрать аллокации из игрового hot path

This commit is contained in:
2026-08-18 17:20:04 +03:00
parent de3b9485c1
commit 88a1ba9b34
3 changed files with 196 additions and 31 deletions
@@ -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<String> 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<String> sourceIds) {
ArrayList<String> 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<String> 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;
}
}
@@ -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<String, Boolean> toggleSignals = new HashMap<>();
private final Map<String, Float> timedSignals = new HashMap<>();
private final Map<String, Boolean> 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<String> signalIds = new HashSet<>();
int activeSignals = 0;
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<String> signalIds = new ArrayList<>();
if (level != null) {
for (LevelDefinition.Switch item : level.switches) signalIds.add(item.signalId);
for (String signalId : signalIds) if (signalActive(signalId)) activeSignals++;
}
accessibilityStateCache.configureSignals(signalIds);
}
private boolean updateCriticalStatusCache() {
int activeSignals = 0;
if (level != null) {
for (String signalId : accessibilityStateCache.signalIds()) {
if (signalActive(signalId)) activeSignals++;
}
}
int openDoors = 0;
if (level != null) {
for (LevelDefinition.Door door : level.doors) if (doorOpen(door)) openDoors++;
return AccessibilityStatus.describe(strings, activeSignals, signalIds.size(), openDoors,
level.doors.size(), player.alive);
}
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);
@@ -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));
}
}