Завершить инженерный кандидат Stage 5

This commit is contained in:
2026-08-19 02:04:45 +03:00
parent b840684ef3
commit cde886ca96
45 changed files with 3236 additions and 98 deletions
@@ -28,17 +28,18 @@ import java.util.Map;
* The Canvas design keeps the prototype small enough for direct APK distribution.
*/
public final class GameView extends View implements Runnable {
private static final PhysicsConfig PHYSICS = PhysicsConfig.release();
private static final float VIEW_WIDTH = 1920f;
private static final float VIEW_HEIGHT = 1080f;
private static final float PLAYER_WIDTH = 62f;
private static final float PLAYER_HEIGHT = 100f;
private static final float GRAVITY = 1800f;
private static final float RUN_SPEED = 370f;
private static final float JUMP_SPEED = 880f;
private static final float COYOTE_SECONDS = 0.13f;
private static final float JUMP_BUFFER_SECONDS = 0.14f;
private static final float RESPAWN_SECONDS = 0.78f;
private static final int MAX_CORPSES = 16;
private static final float GRAVITY = PHYSICS.gravity();
private static final float RUN_SPEED = PHYSICS.runSpeed();
private static final float JUMP_SPEED = PHYSICS.jumpSpeed();
private static final float COYOTE_SECONDS = PHYSICS.coyoteSeconds();
private static final float JUMP_BUFFER_SECONDS = PHYSICS.jumpBufferSeconds();
private static final float RESPAWN_SECONDS = PHYSICS.respawnSeconds();
private static final int MAX_CORPSES = PHYSICS.maximumBodies();
private enum Screen { LEVELS, SETTINGS, PLAYING, COMPLETE }
private enum DeathType { SPIKE, SAW, FREEZE, ELECTRIC, PIT }
@@ -63,6 +64,8 @@ public final class GameView extends View implements Runnable {
private final boolean firstMovingPlatformMovingDown;
private final float firstCorpseX;
private final boolean playerOnGround;
private final float playerVelocityX;
private final float playerVelocityY;
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
boolean visiblyPaused, boolean inputActive, int levelNumber,
@@ -113,6 +116,24 @@ public final class GameView extends View implements Runnable {
boolean firstMovingPlatformMovingRight,
boolean firstMovingPlatformMovingDown, float firstCorpseX,
boolean playerOnGround) {
this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber,
elapsedMilliseconds, playerX, playerY, deaths, corpseCount,
firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform,
firstMovingPlatformPowered, firstMovingPlatformMovingRight,
firstMovingPlatformMovingDown, firstCorpseX, playerOnGround, 0f, 0f);
}
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
boolean visiblyPaused, boolean inputActive, int levelNumber,
long elapsedMilliseconds,
float playerX, float playerY, int deaths, int corpseCount,
float firstMovingPlatformX, float firstMovingPlatformY,
boolean playerOnMovingPlatform,
boolean firstMovingPlatformPowered,
boolean firstMovingPlatformMovingRight,
boolean firstMovingPlatformMovingDown, float firstCorpseX,
boolean playerOnGround, float playerVelocityX,
float playerVelocityY) {
this.completed = completed;
this.playerAlive = playerAlive;
this.allDoorsOpen = allDoorsOpen;
@@ -132,6 +153,8 @@ public final class GameView extends View implements Runnable {
this.firstMovingPlatformMovingDown = firstMovingPlatformMovingDown;
this.firstCorpseX = firstCorpseX;
this.playerOnGround = playerOnGround;
this.playerVelocityX = playerVelocityX;
this.playerVelocityY = playerVelocityY;
}
public boolean completed() { return completed; }
@@ -153,6 +176,8 @@ public final class GameView extends View implements Runnable {
public boolean firstMovingPlatformMovingDown() { return firstMovingPlatformMovingDown; }
public float firstCorpseX() { return firstCorpseX; }
public boolean playerOnGround() { return playerOnGround; }
public float playerVelocityX() { return playerVelocityX; }
public float playerVelocityY() { return playerVelocityY; }
}
private static final class Player {
@@ -204,6 +229,8 @@ public final class GameView extends View implements Runnable {
private final OnboardingStore onboardingStore;
private final GameLanguageStore languageStore;
private final AccessibilitySettingsStore accessibilitySettingsStore;
private final AudioSettingsStore audioSettingsStore;
private final ProceduralAudioEngine audioEngine;
private final SparseArray<PointF> pointers = new SparseArray<>();
private final Player player = new Player();
private final ArrayList<Corpse> corpses = new ArrayList<>();
@@ -213,6 +240,10 @@ public final class GameView extends View implements Runnable {
private final Map<String, Boolean> previousSwitchContact = new HashMap<>();
private final AccessibilityStateCache accessibilityStateCache =
new AccessibilityStateCache();
private final VolunteerCanvasRenderer volunteerRenderer = new VolunteerCanvasRenderer();
private final CameraController cameraController =
new CameraController(VIEW_WIDTH, VIEW_HEIGHT);
private final PerformanceMonitor performanceMonitor = new PerformanceMonitor(240);
private Screen screen = Screen.LEVELS;
private LevelDefinition level;
@@ -221,6 +252,9 @@ public final class GameView extends View implements Runnable {
private float levelElapsed;
private float respawnTimer;
private float cameraX;
private float cameraY;
private float cameraShakeX;
private float cameraShakeY;
private boolean leftPressed;
private boolean rightPressed;
private boolean jumpPressed;
@@ -237,6 +271,13 @@ public final class GameView extends View implements Runnable {
private GameLanguage language;
private GameStrings strings;
private AccessibilitySettings accessibilitySettings;
private AudioSettings audioSettings;
private float lastFootstepAt = -10f;
private boolean previousAllDoorsOpen;
private boolean deterministicAutomationClock;
private double deterministicAccumulatorSeconds;
private float lastDrawDurationMs;
private int performanceFrameCounter;
private String cachedLevelHeader = "";
private String cachedLevelHint = "";
private float cachedLevelHintSize = 25f;
@@ -260,6 +301,9 @@ public final class GameView extends View implements Runnable {
strings = GameStrings.forLanguage(language);
accessibilitySettingsStore = new AccessibilitySettingsStore(context);
accessibilitySettings = accessibilitySettingsStore.load();
audioSettingsStore = new AudioSettingsStore(context);
audioSettings = audioSettingsStore.load();
audioEngine = new ProceduralAudioEngine(audioSettings);
paint.setTypeface(android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL));
stroke.setStyle(Paint.Style.STROKE);
stroke.setStrokeCap(Paint.Cap.ROUND);
@@ -273,17 +317,31 @@ public final class GameView extends View implements Runnable {
@Override
protected void onDetachedFromWindow() {
running = false;
audioEngine.release();
super.onDetachedFromWindow();
}
@Override
public void run() {
if (!running) return;
if (deterministicAutomationClock) {
postDelayed(this, 100L);
return;
}
long now = System.nanoTime();
if (lastFrameNanos == 0L) lastFrameNanos = now;
float dt = Math.min(1f / 30f, (now - lastFrameNanos) / 1_000_000_000f);
float frameIntervalMs = (now - lastFrameNanos) / 1_000_000f;
float dt = Math.min(1f / 30f, frameIntervalMs / 1_000f);
lastFrameNanos = now;
if (!paused && !userPaused && screen == Screen.PLAYING && dt > 0f) update(dt);
long updateStartedAt = System.nanoTime();
if (!deterministicAutomationClock && !paused && !userPaused
&& screen == Screen.PLAYING && dt > 0f) update(dt);
float updateDurationMs = (System.nanoTime() - updateStartedAt) / 1_000_000f;
if (!deterministicAutomationClock && screen == Screen.PLAYING && frameIntervalMs > 0f) {
performanceMonitor.recordFrame(frameIntervalMs, updateDurationMs,
lastDrawDurationMs);
if ((performanceFrameCounter++ % 30) == 0) recordPerformanceComplexity();
}
invalidate();
postOnAnimation(this);
}
@@ -291,6 +349,7 @@ public final class GameView extends View implements Runnable {
public void setPaused(boolean value) {
paused = value;
if (value && screen == Screen.PLAYING) userPaused = true;
if (value || userPaused) audioEngine.pause();
pointers.clear();
deriveControls();
lastFrameNanos = System.nanoTime();
@@ -312,6 +371,7 @@ public final class GameView extends View implements Runnable {
userPaused = false;
} else {
screen = Screen.LEVELS;
audioEngine.pause();
}
pointers.clear();
deriveControls();
@@ -327,6 +387,14 @@ public final class GameView extends View implements Runnable {
levelElapsed = 0f;
respawnTimer = 0f;
cameraX = 0f;
cameraY = 0f;
cameraShakeX = 0f;
cameraShakeY = 0f;
cameraController.reset();
lastFootstepAt = -10f;
previousAllDoorsOpen = false;
performanceMonitor.reset();
performanceFrameCounter = 0;
userPaused = false;
pointers.clear();
deriveControls();
@@ -359,10 +427,12 @@ public final class GameView extends View implements Runnable {
player.jumpBuffer = 0f;
player.supportIndex = -1;
player.facing = 1;
if (deaths > 0) audioEngine.play(AudioCue.RESPAWN_VOICE);
}
private void update(float dt) {
levelElapsed += dt;
audioEngine.updateMusic(levelElapsed, level.number);
updateSignals(dt);
updatePlatforms(dt);
@@ -381,11 +451,31 @@ public final class GameView extends View implements Runnable {
checkGoal();
}
if (player.alive && player.onGround && Math.abs(player.vx) >= 100f
&& levelElapsed - lastFootstepAt >= 0.25f) {
lastFootstepAt = levelElapsed;
audioEngine.play(AudioCue.FOOTSTEP);
}
boolean allDoorsOpen = allDoorsOpenNow();
if (allDoorsOpen && !previousAllDoorsOpen) {
audioEngine.play(AudioCue.DOOR);
if (!level.doors.isEmpty()) {
LevelDefinition.Door door = level.doors.get(0);
cameraController.requestFocus(door.box.x + door.box.width * 0.5f,
door.box.y + door.box.height * 0.5f, 0.35f);
}
}
previousAllDoorsOpen = allDoorsOpen;
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);
CameraController.Frame camera = cameraController.update(dt,
player.x, player.y, player.facing, level.worldWidth, VIEW_HEIGHT,
accessibilitySettings.reducedMotionEnabled());
cameraX = camera.x();
cameraY = camera.y();
cameraShakeX = camera.shakeX();
cameraShakeY = camera.shakeY();
}
private void updateSignals(float dt) {
@@ -405,10 +495,17 @@ public final class GameView extends View implements Runnable {
timedSignals.put(item.signalId, item.durationSeconds);
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
}
if (contact && !previous) audioEngine.play(AudioCue.SWITCH);
previousSwitchContact.put(contactKey, contact);
}
}
private boolean allDoorsOpenNow() {
if (level == null || level.doors.isEmpty()) return false;
for (LevelDefinition.Door door : level.doors) if (!doorOpen(door)) return false;
return true;
}
private boolean switchContact(LevelDefinition.Switch item) {
int mass = 0;
if (player.alive && intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, item.box)) mass++;
@@ -489,6 +586,7 @@ public final class GameView extends View implements Runnable {
player.coyote = 0f;
player.jumpBuffer = 0f;
emitHaptic(HapticFeedbackConstants.CLOCK_TICK);
audioEngine.play(AudioCue.JUMP);
}
player.vy += GRAVITY * dt;
@@ -663,6 +761,9 @@ public final class GameView extends View implements Runnable {
respawnTimer = RESPAWN_SECONDS;
emitHaptic(Build.VERSION.SDK_INT >= 27
? HapticFeedbackConstants.LONG_PRESS : HapticFeedbackConstants.KEYBOARD_TAP);
audioEngine.play(AudioCue.DEATH_IMPACT);
audioEngine.play(AudioCue.DEATH_VOICE);
cameraController.impact(type == DeathType.PIT ? 12f : 24f);
if (type != DeathType.PIT) {
Corpse corpse = new Corpse();
@@ -716,9 +817,11 @@ public final class GameView extends View implements Runnable {
);
player.vx = player.vy = 0f;
screen = Screen.COMPLETE;
audioEngine.pause();
pointers.clear();
deriveControls();
emitHaptic(HapticFeedbackConstants.LONG_PRESS);
audioEngine.play(AudioCue.GOAL_VOICE);
refreshAccessibilityDescription(true);
}
@@ -736,6 +839,7 @@ public final class GameView extends View implements Runnable {
@Override
protected void onDraw(Canvas canvas) {
long drawStartedAt = System.nanoTime();
super.onDraw(canvas);
updateViewport(getWidth(), getHeight());
@@ -757,12 +861,29 @@ public final class GameView extends View implements Runnable {
else if (onboardingFlow.isVisible()) drawOnboarding(canvas);
}
canvas.restore();
lastDrawDurationMs = (System.nanoTime() - drawStartedAt) / 1_000_000f;
}
private void recordPerformanceComplexity() {
if (level == null) return;
int bodies = corpses.size();
int solids = platformStates.size() + level.doors.size() + bodies;
int actors = 1 + bodies;
int collisionChecks = solids * actors * 2
+ level.hazards.size() + level.switches.size() * actors;
int drawPrimitives = 180 + platformStates.size() * 8
+ level.switches.size() * 9 + level.hazards.size() * 20
+ level.doors.size() * 12 + bodies * 28;
Runtime runtime = Runtime.getRuntime();
long usedMemory = runtime.totalMemory() - runtime.freeMemory();
performanceMonitor.recordComplexity(bodies, collisionChecks,
drawPrimitives, usedMemory);
}
private void drawGame(Canvas canvas) {
drawSky(canvas);
canvas.save();
canvas.translate(-cameraX, 0f);
canvas.translate(-cameraX + cameraShakeX, -cameraY + cameraShakeY);
drawWorldBackdrop(canvas);
drawPlatforms(canvas);
drawSwitches(canvas);
@@ -770,11 +891,30 @@ public final class GameView extends View implements Runnable {
drawDoors(canvas);
drawGoal(canvas);
for (Corpse corpse : corpses) drawCorpse(canvas, corpse);
if (player.alive) drawKnight(canvas, player.x, player.y, player.facing, false);
VolunteerRig.Skin skin = VolunteerRig.Skin.forLevel(level.number);
if (player.alive) {
VolunteerRig.Motion motion = VolunteerRig.aliveMotion(
player.onGround, player.vx, player.vy);
volunteerRenderer.draw(canvas, paint, stroke, path,
player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT,
skin, motion, volunteerPhase(motion), player.facing, 1f,
accessibilitySettings.highContrastEnabled());
} else if (respawnTimer > 0f) {
float progress = 1f - GameMath.clamp(respawnTimer / RESPAWN_SECONDS, 0f, 1f);
volunteerRenderer.draw(canvas, paint, stroke, path,
level.spawnX, level.spawnY, PLAYER_WIDTH, PLAYER_HEIGHT,
skin, VolunteerRig.Motion.RESPAWN, progress, 1, 0.72f,
accessibilitySettings.highContrastEnabled());
}
canvas.restore();
drawHud(canvas);
}
private float volunteerPhase(VolunteerRig.Motion motion) {
if (motion == VolunteerRig.Motion.RUN) return levelElapsed * 2.6f;
return levelElapsed * 0.65f;
}
private void drawSky(Canvas canvas) {
if (accessibilitySettings.highContrastEnabled()) {
canvas.drawColor(Color.BLACK);
@@ -920,6 +1060,7 @@ public final class GameView extends View implements Runnable {
}
}
private void drawSaw(Canvas canvas, RectF box) {
float angle = levelElapsed * 4.5f;
canvas.save();
@@ -962,44 +1103,30 @@ public final class GameView extends View implements Runnable {
private void drawGoal(Canvas canvas) {
RectF box = rect(level.goal);
paint.setColor(Color.argb(48, 252, 211, 94));
canvas.drawCircle(box.centerX(), box.centerY(), 92f + (float) Math.sin(levelElapsed * 3f) * 9f, paint);
paint.setColor(Color.rgb(246, 197, 75));
path.reset();
path.moveTo(box.left + 10, box.top + 35);
path.lineTo(box.left + 24, box.top + 5);
path.lineTo(box.centerX(), box.top + 32);
path.lineTo(box.right - 22, box.top + 5);
path.lineTo(box.right - 8, box.top + 35);
path.lineTo(box.right - 20, box.bottom - 8);
path.lineTo(box.left + 20, box.bottom - 8);
path.close();
canvas.drawPath(path, paint);
paint.setColor(Color.rgb(245, 235, 204));
canvas.drawCircle(box.centerX(), box.centerY() + 16, 12, paint);
}
private void drawKnight(Canvas canvas, float x, float y, int facing, boolean corpse) {
float alpha = corpse ? 0.78f : 1f;
paint.setColor(withAlpha(Color.rgb(226, 230, 235), alpha));
canvas.drawRoundRect(new RectF(x + 10, y + 25, x + 52, y + 73), 16, 16, paint);
paint.setColor(withAlpha(Color.rgb(95, 105, 123), alpha));
canvas.drawRoundRect(new RectF(x + 6, y + 46, x + 56, y + 91), 12, 12, paint);
paint.setColor(withAlpha(Color.rgb(177, 62, 69), alpha));
path.reset();
path.moveTo(x + (facing > 0 ? 14 : 48), y + 50);
path.lineTo(x + (facing > 0 ? -8 : 70), y + 92);
path.lineTo(x + 31, y + 80);
path.close();
canvas.drawPath(path, paint);
paint.setColor(withAlpha(Color.rgb(36, 40, 52), alpha));
canvas.drawRect(x + 14, y + 47, x + 48, y + 54, paint);
paint.setColor(withAlpha(Color.rgb(243, 196, 80), alpha));
float eyeX = facing > 0 ? x + 42 : x + 20;
canvas.drawCircle(eyeX, y + 50, 4, paint);
paint.setColor(withAlpha(Color.rgb(64, 72, 91), alpha));
canvas.drawRoundRect(new RectF(x + 10, y + 86, x + 27, y + 100), 5, 5, paint);
canvas.drawRoundRect(new RectF(x + 35, y + 86, x + 52, y + 100), 5, 5, paint);
float pulse = accessibilitySettings.highContrastEnabled()
? 0f : (float) Math.sin(levelElapsed * 3f) * 8f;
paint.setColor(Color.argb(52, 99, 239, 215));
canvas.drawCircle(box.centerX(), box.centerY(), 88f + pulse, paint);
stroke.setStrokeWidth(6f);
stroke.setColor(accessibilitySettings.highContrastEnabled()
? Color.WHITE : Color.rgb(224, 176, 73));
canvas.drawLine(box.centerX(), box.top - 62f,
box.centerX(), box.centerY() - 38f, stroke);
canvas.save();
canvas.rotate(accessibilitySettings.highContrastEnabled()
? 0f : levelElapsed * 24f, box.centerX(), box.centerY());
canvas.drawOval(box.left + 4f, box.centerY() - 26f,
box.right - 4f, box.centerY() + 26f, stroke);
canvas.rotate(62f, box.centerX(), box.centerY());
canvas.drawOval(box.left + 12f, box.centerY() - 18f,
box.right - 12f, box.centerY() + 18f, stroke);
canvas.restore();
paint.setColor(accessibilitySettings.highContrastEnabled()
? Color.rgb(255, 214, 77) : Color.rgb(93, 232, 214));
canvas.drawCircle(box.centerX(), box.centerY(), 24f + pulse * 0.15f, paint);
paint.setColor(accessibilitySettings.highContrastEnabled()
? Color.BLACK : Color.rgb(239, 252, 225));
canvas.drawCircle(box.centerX() - 6f, box.centerY() - 7f, 7f, paint);
}
private void drawCorpse(Canvas canvas, Corpse corpse) {
@@ -1007,23 +1134,16 @@ public final class GameView extends View implements Runnable {
float cx = corpse.x + corpse.width * 0.5f;
float cy = corpse.y + corpse.height * 0.5f;
if (!corpse.frozen) canvas.rotate(90, cx, cy);
if (corpse.frozen) {
paint.setColor(Color.argb(190, 94, 219, 247));
canvas.drawRoundRect(corpse.rect(), 15, 15, paint);
stroke.setColor(Color.rgb(219, 251, 255));
stroke.setStrokeWidth(5f);
canvas.drawRoundRect(new RectF(corpse.x + 7, corpse.y + 7, corpse.x + corpse.width - 7, corpse.y + corpse.height - 7), 10, 10, stroke);
drawKnight(canvas, corpse.x + 20, corpse.y + 2, 1, true);
} else {
float drawX = cx - PLAYER_WIDTH * 0.5f;
float drawY = cy - PLAYER_HEIGHT * 0.5f;
drawKnight(canvas, drawX, drawY, 1, true);
}
float drawX = cx - PLAYER_WIDTH * 0.5f;
float drawY = cy - PLAYER_HEIGHT * 0.5f;
VolunteerRig.Motion motion = VolunteerRig.bodyMotion(
corpse.frozen, corpse.conductive, corpse.pinned);
volunteerRenderer.draw(canvas, paint, stroke, path,
drawX, drawY, PLAYER_WIDTH, PLAYER_HEIGHT,
VolunteerRig.Skin.forLevel(level.number), motion,
levelElapsed * 1.6f, 1, 0.82f,
accessibilitySettings.highContrastEnabled());
canvas.restore();
if (corpse.conductive) {
paint.setColor(Color.argb(120, 249, 239, 94));
canvas.drawCircle(cx, cy, 54 + (float) Math.sin(levelElapsed * 8) * 6, paint);
}
}
private void drawHud(Canvas canvas) {
@@ -1152,6 +1272,20 @@ public final class GameView extends View implements Runnable {
drawCompactSettingRow(canvas, new RectF(55, 450, 385, 580),
strings.highContrastSetting(),
strings.highContrastValue(accessibilitySettings.highContrastEnabled()));
drawCompactSettingRow(canvas, new RectF(55, 620, 385, 750),
strings.reducedMotionSetting(),
strings.reducedMotionValue(accessibilitySettings.reducedMotionEnabled()));
drawCenteredText(canvas, strings.audioTitle(), 1_700f, 240, 25,
Color.rgb(248, 212, 126));
drawCompactSettingRow(canvas, new RectF(1_535, 280, 1_865, 410),
strings.musicSetting(),
strings.volumeValue(audioSettings, audioSettings.musicPreset()));
drawCompactSettingRow(canvas, new RectF(1_535, 450, 1_865, 580),
strings.effectsSetting(),
strings.volumeValue(audioSettings, audioSettings.effectsPreset()));
drawCompactSettingRow(canvas, new RectF(1_535, 620, 1_865, 750),
strings.voiceSetting(),
strings.volumeValue(audioSettings, audioSettings.voicePreset()));
drawHudButton(canvas, new RectF(710, 850, 1210, 960), strings.back(), true);
}
@@ -1431,6 +1565,7 @@ public final class GameView extends View implements Runnable {
}
if (contains(1790, 26, 1892, 112, point.x, point.y)) {
userPaused = true;
audioEngine.pause();
pointers.clear();
deriveControls();
refreshAccessibilityDescription(true);
@@ -1524,7 +1659,6 @@ public final class GameView extends View implements Runnable {
private void advanceOnboarding() {
onboardingFlow.next();
if (onboardingFlow.isComplete()) onboardingStore.markComplete();
invalidate();
}
private void levelsFromResult() {
@@ -1550,6 +1684,7 @@ public final class GameView extends View implements Runnable {
else if (contains(610, 650, 1310, 775, x, y)) {
userPaused = false;
screen = Screen.LEVELS;
audioEngine.pause();
}
refreshAccessibilityDescription(true);
}
@@ -1561,6 +1696,9 @@ public final class GameView extends View implements Runnable {
} else if (contains(55, 450, 385, 580, x, y)) {
updateAccessibilitySettings(accessibilitySettings.toggleHighContrast());
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
} else if (contains(55, 620, 385, 750, x, y)) {
updateAccessibilitySettings(accessibilitySettings.toggleReducedMotion());
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
} else if (contains(1580, 70, 1850, 160, x, y)) {
updateLanguage(language.next());
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
@@ -1573,8 +1711,18 @@ public final class GameView extends View implements Runnable {
} else if (contains(420, 620, 1500, 750, x, y)) {
updateControlSettings(controlSettings.toggleHaptics());
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
} else if (contains(1535, 280, 1865, 410, x, y)) {
updateAudioSettings(audioSettings.nextMusicVolume());
audioEngine.play(AudioCue.UI_TAP);
} else if (contains(1535, 450, 1865, 580, x, y)) {
updateAudioSettings(audioSettings.nextEffectsVolume());
audioEngine.play(AudioCue.UI_TAP);
} else if (contains(1535, 620, 1865, 750, x, y)) {
updateAudioSettings(audioSettings.nextVoiceVolume());
audioEngine.play(AudioCue.UI_TAP);
} else if (contains(710, 850, 1210, 960, x, y)) {
screen = Screen.LEVELS;
audioEngine.play(AudioCue.UI_TAP);
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
refreshAccessibilityDescription(true);
}
@@ -1611,6 +1759,42 @@ public final class GameView extends View implements Runnable {
startLevel(oneBasedLevelNumber - 1);
}
public void enableDeterministicAutomationClock() {
deterministicAutomationClock = true;
deterministicAccumulatorSeconds = 0d;
audioEngine.setSynthesisEnabled(false);
lastFrameNanos = System.nanoTime();
}
public void disableDeterministicAutomationClock() {
deterministicAutomationClock = false;
deterministicAccumulatorSeconds = 0d;
audioEngine.setSynthesisEnabled(true);
lastFrameNanos = System.nanoTime();
}
public void advanceDeterministicAutomation(long milliseconds) {
if (!deterministicAutomationClock) {
throw new IllegalStateException("Deterministic automation clock is disabled");
}
if (milliseconds < 0L || milliseconds > 120_000L) {
throw new IllegalArgumentException("Invalid deterministic advance: " + milliseconds);
}
deterministicAccumulatorSeconds += milliseconds / 1_000d;
double fixedStep = PHYSICS.fixedStepSeconds();
while (deterministicAccumulatorSeconds + 1e-9d >= fixedStep) {
if (!userPaused && screen == Screen.PLAYING) update((float) fixedStep);
deterministicAccumulatorSeconds -= fixedStep;
}
}
public String deterministicStateSignatureForAutomation() {
GameSessionSnapshot snapshot = captureActiveSession();
if (snapshot == null) throw new IllegalStateException("No puzzle state to sign");
return ReplayStateSignature.of(VersionedSessionCodec.encode(snapshot),
screen == Screen.COMPLETE, allDoorsOpenNow());
}
public void restartLevelForAutomation() {
restartLevel();
}
@@ -1749,6 +1933,15 @@ public final class GameView extends View implements Runnable {
visible.add(strings.textSizeValue(accessibilitySettings.textScale()));
visible.add(strings.highContrastSetting());
visible.add(strings.highContrastValue(accessibilitySettings.highContrastEnabled()));
visible.add(strings.reducedMotionSetting());
visible.add(strings.reducedMotionValue(accessibilitySettings.reducedMotionEnabled()));
visible.add(strings.audioTitle());
visible.add(strings.musicSetting());
visible.add(strings.volumeValue(audioSettings, audioSettings.musicPreset()));
visible.add(strings.effectsSetting());
visible.add(strings.volumeValue(audioSettings, audioSettings.effectsPreset()));
visible.add(strings.voiceSetting());
visible.add(strings.volumeValue(audioSettings, audioSettings.voicePreset()));
visible.add(strings.back());
} else if (level != null) {
visible.add(String.format(Locale.US, "%02d %s", level.number,
@@ -1800,6 +1993,34 @@ public final class GameView extends View implements Runnable {
return accessibilitySettings;
}
public AudioSettings audioSettingsForAutomation() {
return audioSettings;
}
public void resetAudioSettingsForAutomation() {
updateAudioSettings(AudioSettings.defaults());
}
public void selectNextMusicVolumeForAutomation() {
updateAudioSettings(audioSettings.nextMusicVolume());
}
public void selectNextEffectsVolumeForAutomation() {
updateAudioSettings(audioSettings.nextEffectsVolume());
}
public void selectNextVoiceVolumeForAutomation() {
updateAudioSettings(audioSettings.nextVoiceVolume());
}
public AudioCue lastAudioCueForAutomation() {
return audioEngine.lastCue();
}
public int audioCueCountForAutomation() {
return audioEngine.cueCount();
}
public void resetAccessibilitySettingsForAutomation() {
updateAccessibilitySettings(AccessibilitySettings.defaults());
}
@@ -1812,6 +2033,10 @@ public final class GameView extends View implements Runnable {
updateAccessibilitySettings(accessibilitySettings.toggleHighContrast());
}
public void toggleReducedMotionForAutomation() {
updateAccessibilitySettings(accessibilitySettings.toggleReducedMotion());
}
public float textScaleForAutomation() {
return accessibilitySettings.textScale();
}
@@ -1820,6 +2045,26 @@ public final class GameView extends View implements Runnable {
return accessibilitySettings.highContrastEnabled();
}
public boolean reducedMotionForAutomation() {
return accessibilitySettings.reducedMotionEnabled();
}
public float[] cameraStateForAutomation() {
CameraController.Frame camera = cameraController.frame();
return new float[] {camera.x(), camera.y(), camera.shakeX(), camera.shakeY()};
}
public PerformanceMonitor.Snapshot performanceSnapshotForAutomation() {
recordPerformanceComplexity();
return performanceMonitor.snapshot();
}
public void resetPerformanceMetricsForAutomation() {
performanceMonitor.reset();
performanceFrameCounter = 0;
lastFrameNanos = System.nanoTime();
}
public String screenDescriptionForAutomation() {
return currentAccessibilityDescription();
}
@@ -1836,6 +2081,7 @@ public final class GameView extends View implements Runnable {
public void pauseForAutomation() {
if (screen == Screen.PLAYING) {
userPaused = true;
audioEngine.pause();
pointers.clear();
deriveControls();
refreshAccessibilityDescription(false);
@@ -1845,6 +2091,7 @@ public final class GameView extends View implements Runnable {
public void showCompleteForAutomation() {
if (level != null) {
screen = Screen.COMPLETE;
audioEngine.pause();
pointers.clear();
deriveControls();
refreshAccessibilityDescription(false);
@@ -1878,6 +2125,14 @@ public final class GameView extends View implements Runnable {
invalidate();
}
private void updateAudioSettings(AudioSettings updated) {
audioSettings = updated == null ? AudioSettings.defaults() : updated;
audioSettingsStore.save(audioSettings);
audioEngine.updateSettings(audioSettings);
refreshAccessibilityDescription(false);
invalidate();
}
private void invalidateHudTextCache() {
cachedHudLevelNumber = -1;
cachedHudLanguage = null;
@@ -1952,7 +2207,7 @@ public final class GameView extends View implements Runnable {
}
if (screen == Screen.SETTINGS) {
return AccessibilityScreenDescription.settings(strings, controlSettings,
accessibilitySettings);
accessibilitySettings, audioSettings);
}
if (screen == Screen.COMPLETE) {
return AccessibilityScreenDescription.complete(strings, level, deaths,
@@ -1988,6 +2243,142 @@ public final class GameView extends View implements Runnable {
if (jump && !jumpWasPressed && player.alive) player.jumpBuffer = JUMP_BUFFER_SECONDS;
}
public GameSessionSnapshot sessionSnapshotForAutomation() {
return captureActiveSession();
}
byte[] saveSessionForActivityState() {
if (screen != Screen.PLAYING) return null;
GameSessionSnapshot snapshot = captureActiveSession();
return snapshot == null ? null : VersionedSessionCodec.encode(snapshot);
}
boolean restoreSessionFromActivityState(byte[] encoded) {
VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode(encoded);
if (decoded.snapshot() == null) return false;
return restoreActiveSession(decoded.snapshot());
}
private GameSessionSnapshot captureActiveSession() {
if ((screen != Screen.PLAYING && screen != Screen.COMPLETE) || level == null) return null;
GameSessionSnapshot.Builder builder = GameSessionSnapshot.builder(level.number)
.elapsedSeconds(levelElapsed)
.deaths(deaths)
.respawnSeconds(Math.max(0f, respawnTimer))
.player(new GameSessionSnapshot.PlayerData(
player.x, player.y, player.vx, player.vy,
player.alive, player.onGround, player.coyote,
player.jumpBuffer, player.supportIndex, player.facing));
for (Corpse corpse : corpses) {
builder.addBody(new GameSessionSnapshot.BodyData(
corpse.x, corpse.y, corpse.width, corpse.height,
corpse.vx, corpse.vy, corpse.frozen, corpse.conductive,
corpse.pinned, corpse.grounded, corpse.supportIndex,
corpse.type == null ? DeathType.SPIKE.name() : corpse.type.name()));
}
ArrayList<Float> motionTimes = new ArrayList<>(platformStates.size());
for (PlatformState state : platformStates) motionTimes.add(state.motionTime);
builder.platformMotionTimes(motionTimes);
for (Map.Entry<String, Boolean> entry : toggleSignals.entrySet()) {
builder.toggleSignal(entry.getKey(), Boolean.TRUE.equals(entry.getValue()));
}
for (Map.Entry<String, Float> entry : timedSignals.entrySet()) {
builder.timedSignal(entry.getKey(), Math.max(0f, entry.getValue()));
}
for (Map.Entry<String, Boolean> entry : previousSwitchContact.entrySet()) {
builder.previousSwitchContact(entry.getKey(), Boolean.TRUE.equals(entry.getValue()));
}
return builder.build();
}
private boolean restoreActiveSession(GameSessionSnapshot snapshot) {
int index = snapshot.levelNumber() - 1;
if (index < 0 || index >= LevelCatalog.all().size()) return false;
LevelDefinition definition = LevelCatalog.get(index);
if (snapshot.platformMotionTimes().size() != definition.platforms.size()
|| snapshot.bodies().size() > MAX_CORPSES) return false;
if (!validSupport(snapshot.player().supportIndex(), definition.platforms.size())) {
return false;
}
for (GameSessionSnapshot.BodyData body : snapshot.bodies()) {
if (!validSupport(body.supportIndex(), definition.platforms.size())) return false;
try {
DeathType.valueOf(body.deathType());
} catch (IllegalArgumentException unsupportedType) {
return false;
}
}
startLevel(index);
levelElapsed = snapshot.elapsedSeconds();
deaths = snapshot.deaths();
respawnTimer = snapshot.respawnSeconds();
toggleSignals.clear();
toggleSignals.putAll(snapshot.toggleSignals());
timedSignals.clear();
timedSignals.putAll(snapshot.timedSignals());
previousSwitchContact.clear();
previousSwitchContact.putAll(snapshot.previousSwitchContacts());
for (int i = 0; i < platformStates.size(); i++) {
PlatformState state = platformStates.get(i);
state.motionTime = snapshot.platformMotionTimes().get(i);
float phase = state.definition.isMoving()
? GameMath.pingPong(state.motionTime, state.definition.periodSeconds) : 0f;
state.x = state.previousX = state.definition.box.x + state.definition.travelX * phase;
state.y = state.previousY = state.definition.box.y + state.definition.travelY * phase;
}
GameSessionSnapshot.PlayerData savedPlayer = snapshot.player();
player.x = savedPlayer.x();
player.y = savedPlayer.y();
player.vx = savedPlayer.velocityX();
player.vy = savedPlayer.velocityY();
player.alive = savedPlayer.alive();
player.onGround = savedPlayer.onGround();
player.coyote = savedPlayer.coyoteSeconds();
player.jumpBuffer = savedPlayer.jumpBufferSeconds();
player.supportIndex = savedPlayer.supportIndex();
player.facing = savedPlayer.facing();
corpses.clear();
for (GameSessionSnapshot.BodyData savedBody : snapshot.bodies()) {
Corpse corpse = new Corpse();
corpse.x = savedBody.x();
corpse.y = savedBody.y();
corpse.width = savedBody.width();
corpse.height = savedBody.height();
corpse.vx = savedBody.velocityX();
corpse.vy = savedBody.velocityY();
corpse.frozen = savedBody.frozen();
corpse.conductive = savedBody.conductive();
corpse.pinned = savedBody.pinned();
corpse.grounded = savedBody.grounded();
corpse.supportIndex = savedBody.supportIndex();
corpse.type = DeathType.valueOf(savedBody.deathType());
corpses.add(corpse);
}
leftPressed = false;
rightPressed = false;
jumpPressed = false;
pointers.clear();
userPaused = true;
paused = false;
lastFootstepAt = levelElapsed;
previousAllDoorsOpen = allDoorsOpenNow();
cameraController.reset();
cameraX = cameraY = cameraShakeX = cameraShakeY = 0f;
lastFrameNanos = System.nanoTime();
invalidateHudTextCache();
refreshAccessibilityDescription(true);
invalidate();
return true;
}
private static boolean validSupport(int supportIndex, int platformCount) {
return supportIndex >= -1 && supportIndex < platformCount;
}
public AutomationSnapshot automationSnapshot() {
boolean allOpen = level != null && !level.doors.isEmpty();
if (level != null) {
@@ -2031,7 +2422,9 @@ public final class GameView extends View implements Runnable {
firstMovingPlatformMovingRight,
firstMovingPlatformMovingDown,
corpses.isEmpty() ? Float.NaN : corpses.get(0).x,
player.onGround
player.onGround,
player.vx,
player.vy
);
}
}