package com.projectsacrifice.game; import com.projectsacrifice.game.CorpseManager.Body; import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.LinearGradient; import android.graphics.Paint; import android.graphics.Path; import android.graphics.PointF; import android.graphics.RectF; import android.graphics.Shader; import android.os.Build; 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.HashMap; import java.util.IdentityHashMap; import java.util.List; import java.util.Locale; import java.util.Map; /** * A dependency-free 2D puzzle-platformer renderer and deterministic physics loop. * 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 = 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 static final int LEVELS_PER_PAGE = 10; private enum Screen { LEVELS, SETTINGS, PLAYING, COMPLETE } private enum DeathType { SPIKE, SAW, FREEZE, ELECTRIC, PIT, CRUSH, FIRE, PROJECTILE, EXPLOSION } public static final class AutomationSnapshot { private final boolean completed; private final boolean playerAlive; private final boolean allDoorsOpen; private final boolean visiblyPaused; private final boolean inputActive; private final int levelNumber; private final long elapsedMilliseconds; private final float playerX; private final float playerY; private final int deaths; private final int corpseCount; private final float firstMovingPlatformX; private final float firstMovingPlatformY; private final boolean playerOnMovingPlatform; private final boolean firstMovingPlatformPowered; private final boolean firstMovingPlatformMovingRight; private final boolean firstMovingPlatformMovingDown; private final float firstCorpseX; private final boolean playerOnGround; private final float playerVelocityX; private final float playerVelocityY; private final boolean firstHazardActive; private final float firstHazardX; private final float firstHazardY; AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen, boolean visiblyPaused, boolean inputActive, int levelNumber, long elapsedMilliseconds, float playerX, float playerY, int deaths, int corpseCount) { this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber, elapsedMilliseconds, playerX, playerY, deaths, corpseCount, Float.NaN, Float.NaN, false, false, false, Float.NaN, false); } 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, float firstCorpseX, boolean playerOnGround) { this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber, elapsedMilliseconds, playerX, playerY, deaths, corpseCount, firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform, firstMovingPlatformPowered, false, firstCorpseX, playerOnGround); } 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, float firstCorpseX, boolean playerOnGround) { this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber, elapsedMilliseconds, playerX, playerY, deaths, corpseCount, firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform, firstMovingPlatformPowered, firstMovingPlatformMovingRight, false, firstCorpseX, playerOnGround); } 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) { 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, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber, elapsedMilliseconds, playerX, playerY, deaths, corpseCount, firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform, firstMovingPlatformPowered, firstMovingPlatformMovingRight, firstMovingPlatformMovingDown, firstCorpseX, playerOnGround, playerVelocityX, playerVelocityY, false, Float.NaN, Float.NaN); } 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, boolean firstHazardActive, float firstHazardX, float firstHazardY) { this.completed = completed; this.playerAlive = playerAlive; this.allDoorsOpen = allDoorsOpen; this.visiblyPaused = visiblyPaused; this.inputActive = inputActive; this.levelNumber = levelNumber; this.elapsedMilliseconds = elapsedMilliseconds; this.playerX = playerX; this.playerY = playerY; this.deaths = deaths; this.corpseCount = corpseCount; this.firstMovingPlatformX = firstMovingPlatformX; this.firstMovingPlatformY = firstMovingPlatformY; this.playerOnMovingPlatform = playerOnMovingPlatform; this.firstMovingPlatformPowered = firstMovingPlatformPowered; this.firstMovingPlatformMovingRight = firstMovingPlatformMovingRight; this.firstMovingPlatformMovingDown = firstMovingPlatformMovingDown; this.firstCorpseX = firstCorpseX; this.playerOnGround = playerOnGround; this.playerVelocityX = playerVelocityX; this.playerVelocityY = playerVelocityY; this.firstHazardActive = firstHazardActive; this.firstHazardX = firstHazardX; this.firstHazardY = firstHazardY; } public boolean completed() { return completed; } public boolean playerAlive() { return playerAlive; } public boolean allDoorsOpen() { return allDoorsOpen; } public boolean visiblyPaused() { return visiblyPaused; } public boolean inputActive() { return inputActive; } public int levelNumber() { return levelNumber; } public long elapsedMilliseconds() { return elapsedMilliseconds; } public float playerX() { return playerX; } public float playerY() { return playerY; } public int deaths() { return deaths; } public int corpseCount() { return corpseCount; } public float firstMovingPlatformX() { return firstMovingPlatformX; } public float firstMovingPlatformY() { return firstMovingPlatformY; } public boolean playerOnMovingPlatform() { return playerOnMovingPlatform; } public boolean firstMovingPlatformPowered() { return firstMovingPlatformPowered; } public boolean firstMovingPlatformMovingRight() { return firstMovingPlatformMovingRight; } 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; } public boolean firstHazardActive() { return firstHazardActive; } public float firstHazardX() { return firstHazardX; } public float firstHazardY() { return firstHazardY; } } private static final class Player { float x, y, vx, vy; boolean alive; boolean onGround; float coyote; float jumpBuffer; int supportIndex = -1; int facing = 1; } private static final class PlatformState { float x, y, previousX, previousY, motionTime; final LevelDefinition.Platform definition; PlatformState(LevelDefinition.Platform definition) { this.definition = definition; x = previousX = definition.box.x; y = previousY = definition.box.y; } RectF rect() { return new RectF(x, y, x + definition.box.width, y + definition.box.height); } } private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint stroke = new Paint(Paint.ANTI_ALIAS_FLAG); private final Path path = new Path(); private final LinearGradient outsideGradient = new LinearGradient( 0, 0, 0, VIEW_HEIGHT, Color.rgb(18, 27, 50), Color.rgb(71, 74, 92), Shader.TileMode.CLAMP); private final LinearGradient skyGradient = new LinearGradient( 0, 0, 0, VIEW_HEIGHT, Color.rgb(20, 29, 55), Color.rgb(110, 81, 91), Shader.TileMode.CLAMP); private final ProgressStore progress; private final ControlSettingsStore controlSettingsStore; private final OnboardingStore onboardingStore; private final GameLanguageStore languageStore; private final AccessibilitySettingsStore accessibilitySettingsStore; private final AudioSettingsStore audioSettingsStore; private final ProceduralAudioEngine audioEngine; private final SparseArray pointers = new SparseArray<>(); private final Player player = new Player(); private final CharacterStateMachine characterStateMachine = new CharacterStateMachine(PHYSICS); private final CorpseManager corpseManager = new CorpseManager(MAX_CORPSES); private final List corpses = corpseManager.bodies(); private final ArrayList platformStates = new ArrayList<>(); private final ArrayList hazardRuntimes = new ArrayList<>(); private final Map previousSwitchContact = new HashMap<>(); private final Map doorControllers = new IdentityHashMap<>(); private final Map doorsById = new HashMap<>(); private PuzzleSystem puzzleSystem = new PuzzleSystem(); 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 final LevelPage levelPage = new LevelPage(LevelCatalog.all().size(), LEVELS_PER_PAGE); private Screen screen = Screen.LEVELS; private LevelDefinition level; private int levelIndex; private int deaths; 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; private boolean paused; private boolean userPaused; private boolean running; private long lastFrameNanos; private float canvasScale = 1f; private float canvasOffsetX; private float canvasOffsetY; private ControlSettings controlSettings; private OnboardingFlow onboardingFlow; private CompletionResult completionResult; private GameLanguage language; private GameStrings strings; private AccessibilitySettings accessibilitySettings; private AudioSettings audioSettings; private float lastFootstepAt = -10f; private boolean deterministicAutomationClock; private double deterministicAccumulatorSeconds; private float lastDrawDurationMs; private int performanceFrameCounter; 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); progress = new ProgressStore(context); controlSettingsStore = new ControlSettingsStore(context); controlSettings = controlSettingsStore.load(); onboardingStore = new OnboardingStore(context); onboardingFlow = OnboardingFlow.firstRun(onboardingStore.isComplete()); languageStore = new GameLanguageStore(context); language = languageStore.load(); 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); setFocusable(true); setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); refreshAccessibilityDescription(false); running = true; postOnAnimation(this); } @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 frameIntervalMs = (now - lastFrameNanos) / 1_000_000f; float dt = Math.min(1f / 30f, frameIntervalMs / 1_000f); lastFrameNanos = now; 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); } 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(); 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) { levelsFromResult(); return true; } if (userPaused) { userPaused = false; } else { screen = Screen.LEVELS; audioEngine.pause(); } pointers.clear(); deriveControls(); refreshAccessibilityDescription(true); return true; } private void startLevel(int index) { levelIndex = index; level = LevelCatalog.get(index); screen = Screen.PLAYING; deaths = 0; levelElapsed = 0f; respawnTimer = 0f; cameraX = 0f; cameraY = 0f; cameraShakeX = 0f; cameraShakeY = 0f; cameraController.reset(); lastFootstepAt = -10f; performanceMonitor.reset(); performanceFrameCounter = 0; userPaused = false; pointers.clear(); deriveControls(); corpseManager.reset(); platformStates.clear(); hazardRuntimes.clear(); previousSwitchContact.clear(); configurePuzzleSystem(); completionResult = null; configureAccessibilitySignals(); invalidateHudTextCache(); for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item)); for (LevelDefinition.Hazard item : level.hazards) { hazardRuntimes.add(new HazardRuntime(item)); } spawnPlayer(); lastFrameNanos = System.nanoTime(); refreshAccessibilityDescription(true); } private void restartLevel() { if (level != null) startLevel(levelIndex); } private void spawnPlayer() { player.x = level.spawnX; player.y = level.spawnY; player.vx = 0f; player.vy = 0f; player.alive = true; player.onGround = false; player.coyote = 0f; player.jumpBuffer = 0f; player.supportIndex = -1; player.facing = 1; characterStateMachine.reset(player.alive, player.onGround, player.vx, player.vy, player.facing); if (deaths > 0) audioEngine.play(AudioCue.RESPAWN_VOICE); } private void update(float dt) { levelElapsed += dt; audioEngine.updateMusic(levelElapsed, level.number); for (HazardRuntime runtime : hazardRuntimes) runtime.update(dt); updateSignals(dt); updatePlatforms(dt); int substeps = 2; float step = dt / substeps; for (int i = 0; i < substeps; i++) { updateCorpses(step); if (player.alive) updatePlayer(step); } if (!player.alive) { respawnTimer -= dt; if (respawnTimer <= 0f) spawnPlayer(); } else { checkHazards(); checkGoal(); } if (player.alive && player.onGround && Math.abs(player.vx) >= 100f && levelElapsed - lastFootstepAt >= 0.25f) { lastFootstepAt = levelElapsed; audioEngine.play(AudioCue.FOOTSTEP); } refreshAccessibilityDescriptionIfStateChanged(); 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 configurePuzzleSystem() { doorControllers.clear(); doorsById.clear(); ArrayList controllers = new ArrayList<>(level.doors.size()); for (int index = 0; index < level.doors.size(); index++) { LevelDefinition.Door door = level.doors.get(index); String id = "door-" + index; DoorController controller = new DoorController(id, door.requiredSignals); controllers.add(controller); doorControllers.put(door, controller); doorsById.put(id, door); } puzzleSystem = new PuzzleSystem(controllers); } private void updateSignals(float dt) { updateSignals(dt, true); } private void updateSignals(float dt, boolean feedback) { ArrayList inputs = new ArrayList<>( level.switches.size() + level.hazards.size()); for (LevelDefinition.Switch item : level.switches) { boolean contact = switchContact(item); String contactKey = item.signalId + "@" + item.box.x; boolean previous = Boolean.TRUE.equals(previousSwitchContact.get(contactKey)); switch (item.type) { case PRESSURE: inputs.add(PuzzleSystem.Input.pressure( contactKey, item.signalId, contact)); break; case TOGGLE: inputs.add(PuzzleSystem.Input.toggle( contactKey, item.signalId, contact)); break; case TIMED: inputs.add(PuzzleSystem.Input.timed( contactKey, item.signalId, contact, item.durationSeconds)); break; } if (feedback && contact && !previous && item.type != LevelDefinition.SwitchType.PRESSURE) { emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); } if (feedback && contact && !previous) audioEngine.play(AudioCue.SWITCH); if (feedback && contact && !previous && player.alive && intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, item.box)) { characterStateMachine.beginInteraction(); } previousSwitchContact.put(contactKey, contact); } for (int index = 0; index < level.hazards.size(); index++) { LevelDefinition.Hazard hazard = level.hazards.get(index); if (hazard.relayId == null || hazard.relayId.isEmpty()) continue; boolean active = false; for (Body corpse : corpses) { if (corpse.conductive && intersects(corpse.x, corpse.y, corpse.width, corpse.height, hazard.box)) { active = true; break; } } inputs.add(PuzzleSystem.Input.relay( "relay-" + index, hazard.relayId, active)); } puzzleSystem.update(dt, inputs); if (!feedback) return; for (PuzzleSystem.Event event : puzzleSystem.events()) { if (event.kind() != PuzzleSystem.Event.Kind.DOOR_OPENED) continue; audioEngine.play(AudioCue.DOOR); LevelDefinition.Door door = doorsById.get(event.targetId()); if (door != null) { cameraController.requestFocus(door.box.x + door.box.width * 0.5f, door.box.y + door.box.height * 0.5f, 0.35f); } } } 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++; for (Body corpse : corpses) { if (intersects(corpse.x, corpse.y, corpse.width, corpse.height, item.box)) mass++; } return mass >= item.requiredMass; } private boolean signalActive(String id) { return puzzleSystem.signalActive(id); } private boolean doorOpen(LevelDefinition.Door door) { DoorController controller = doorControllers.get(door); return controller != null && controller.open(); } private void updatePlatforms(float dt) { for (int i = 0; i < platformStates.size(); i++) { PlatformState state = platformStates.get(i); state.previousX = state.x; state.previousY = state.y; if (state.definition.isMoving() && signalActive(state.definition.controlId)) state.motionTime += dt; float phase = state.definition.isMoving() ? GameMath.pingPong(state.motionTime, state.definition.periodSeconds) : 0f; state.x = state.definition.box.x + state.definition.travelX * phase; state.y = state.definition.box.y + state.definition.travelY * phase; } if (player.alive && player.supportIndex >= 0 && player.supportIndex < platformStates.size()) { PlatformState support = platformStates.get(player.supportIndex); player.x += support.x - support.previousX; player.y += support.y - support.previousY; } for (Body corpse : corpses) { if (!corpse.frozen && corpse.supportIndex >= 0 && corpse.supportIndex < platformStates.size()) { PlatformState support = platformStates.get(corpse.supportIndex); corpse.x += support.x - support.previousX; corpse.y += support.y - support.previousY; } } } private void updatePlayer(float dt) { player.jumpBuffer = Math.max(0f, player.jumpBuffer - dt); player.coyote = player.onGround ? COYOTE_SECONDS : Math.max(0f, player.coyote - dt); float direction = (rightPressed ? 1f : 0f) - (leftPressed ? 1f : 0f); if (direction != 0f) { player.vx = direction * RUN_SPEED; player.facing = direction > 0 ? 1 : -1; } else { player.vx *= (float) Math.pow(0.002, dt); if (Math.abs(player.vx) < 3f) player.vx = 0f; } if (player.jumpBuffer > 0f && player.coyote > 0f) { player.vy = -JUMP_SPEED; player.onGround = false; player.coyote = 0f; player.jumpBuffer = 0f; emitHaptic(HapticFeedbackConstants.CLOCK_TICK); audioEngine.play(AudioCue.JUMP); } player.vy += GRAVITY * dt; movePlayerHorizontal(player.vx * dt); movePlayerVertical(player.vy * dt); player.x = GameMath.clamp(player.x, 0f, level.worldWidth - PLAYER_WIDTH); if (player.y > VIEW_HEIGHT + 180f) kill(DeathType.PIT, null); characterStateMachine.observe(dt, player.alive, player.onGround, player.vx, player.vy, player.facing); } private void movePlayerHorizontal(float amount) { if (amount == 0f) return; player.x += amount; RectF current = playerRect(); for (RectF solid : solidRects(true)) { if (!RectF.intersects(current, solid)) continue; if (amount > 0f) player.x = solid.left - PLAYER_WIDTH; else player.x = solid.right; player.vx = 0f; current = playerRect(); } } private void movePlayerVertical(float amount) { player.y += amount; player.onGround = false; player.supportIndex = -1; RectF current = playerRect(); for (int i = 0; i < platformStates.size(); i++) { RectF solid = platformStates.get(i).rect(); if (!RectF.intersects(current, solid)) continue; if (amount >= 0f) { player.y = solid.top - PLAYER_HEIGHT; player.vy = 0f; player.onGround = true; player.supportIndex = i; } else { player.y = solid.bottom; player.vy = 0f; } current = playerRect(); } for (LevelDefinition.Door door : level.doors) { if (doorOpen(door)) continue; RectF solid = rect(door.box); if (!RectF.intersects(current, solid)) continue; if (amount >= 0f) { player.y = solid.top - PLAYER_HEIGHT; player.onGround = true; } else player.y = solid.bottom; player.vy = 0f; current = playerRect(); } for (Body corpse : corpses) { RectF solid = corpseRect(corpse); if (!RectF.intersects(current, solid)) continue; if (amount >= 0f) { player.y = solid.top - PLAYER_HEIGHT; player.vy = 0f; player.onGround = true; } else { player.y = solid.bottom; player.vy = 0f; } current = playerRect(); } } private void updateCorpses(float dt) { for (Body corpse : corpses) { if (corpse.frozen || corpse.pinned) continue; corpse.vy += GRAVITY * dt; corpse.grounded = false; corpse.x += corpse.vx * dt; resolveCorpseHorizontal(corpse); corpse.y += corpse.vy * dt; resolveCorpseVertical(corpse); if (corpse.grounded && settleCorpseOnPressurePlate(corpse)) continue; if (corpse.grounded) corpse.vx *= (float) Math.pow(0.05, dt); if (Math.abs(corpse.vx) < 4f) corpse.vx = 0f; } } private boolean settleCorpseOnPressurePlate(Body corpse) { for (LevelDefinition.Switch item : level.switches) { if (item.type != LevelDefinition.SwitchType.PRESSURE) continue; if (!intersects(corpse.x, corpse.y, corpse.width, corpse.height, item.box)) continue; if (item.box.width >= corpse.width) { corpse.x = GameMath.clamp(corpse.x, item.box.x, item.box.right() - corpse.width); } else { corpse.x = item.box.x + (item.box.width - corpse.width) * 0.5f; } corpse.vx = 0f; corpse.vy = 0f; corpseManager.transform(corpse, CorpseManager.Transformation.PINNED); return true; } return false; } private void resolveCorpseHorizontal(Body corpse) { RectF current = corpseRect(corpse); for (RectF solid : solidRects(false)) { if (!RectF.intersects(current, solid)) continue; if (corpse.vx > 0f) corpse.x = solid.left - corpse.width; else if (corpse.vx < 0f) corpse.x = solid.right; corpse.vx *= -0.18f; current = corpseRect(corpse); } } private void resolveCorpseVertical(Body corpse) { corpse.supportIndex = -1; RectF current = corpseRect(corpse); for (int i = 0; i < platformStates.size(); i++) { RectF solid = platformStates.get(i).rect(); if (!RectF.intersects(current, solid)) continue; if (corpse.vy >= 0f) { corpse.y = solid.top - corpse.height; corpse.vy = 0f; corpse.grounded = true; corpse.supportIndex = i; } else { corpse.y = solid.bottom; corpse.vy *= -0.1f; } current = corpseRect(corpse); } for (LevelDefinition.Door door : level.doors) { if (doorOpen(door)) continue; RectF solid = rect(door.box); if (!RectF.intersects(current, solid)) continue; if (corpse.vy >= 0f) { corpse.y = solid.top - corpse.height; corpse.vy = 0f; corpse.grounded = true; } else { corpse.y = solid.bottom; corpse.vy *= -0.1f; } current = corpseRect(corpse); } } private List solidRects(boolean includeCorpses) { ArrayList result = new ArrayList<>(); for (PlatformState platform : platformStates) result.add(platform.rect()); for (LevelDefinition.Door door : level.doors) if (!doorOpen(door)) result.add(rect(door.box)); if (includeCorpses) for (Body corpse : corpses) result.add(corpseRect(corpse)); return result; } private void checkHazards() { for (int index = 0; index < level.hazards.size(); index++) { LevelDefinition.Hazard hazard = level.hazards.get(index); HazardRuntime runtime = hazardRuntimes.get(index); if (!runtime.active()) continue; if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, runtime.currentBox())) continue; switch (hazard.type) { case SPIKES: kill(DeathType.SPIKE, hazard); return; case SAW: kill(DeathType.SAW, hazard); return; case FREEZER: kill(DeathType.FREEZE, hazard); return; case ELECTRIC: kill(DeathType.ELECTRIC, hazard); return; case PIT: kill(DeathType.PIT, hazard); return; case CRUSHER: kill(DeathType.CRUSH, hazard); return; case FIRE: kill(DeathType.FIRE, hazard); return; case PROJECTILE: kill(DeathType.PROJECTILE, hazard); return; case EXPLOSION: kill(DeathType.EXPLOSION, hazard); return; } } } private void kill(DeathType type, LevelDefinition.Hazard source) { if (!player.alive) return; player.alive = false; player.vx = player.vy = 0f; player.supportIndex = -1; characterStateMachine.observe(0f, false, player.onGround, player.vx, player.vy, player.facing); deaths++; respawnTimer = RESPAWN_SECONDS; emitHaptic(Build.VERSION.SDK_INT >= 27 ? HapticFeedbackConstants.LONG_PRESS : HapticFeedbackConstants.KEYBOARD_TAP); audioEngine.play(hazardAudioCue(source)); audioEngine.play(AudioCue.DEATH_VOICE); cameraController.impact(type == DeathType.PIT ? 12f : 24f); if (type != DeathType.PIT) { Body corpse = corpseManager.register(type.name(), player.x, player.y + PLAYER_HEIGHT - 54f); if (source != null) { corpse.vx = source.impulseX; corpse.vy = source.impulseY; } if (type == DeathType.SPIKE && source != null) { corpse.y = source.box.y - corpse.height; corpse.vx = corpse.vy = 0f; corpseManager.transform(corpse, CorpseManager.Transformation.PINNED); } else if (type == DeathType.FREEZE && source != null) { corpse.width = 104f; corpse.height = 104f; corpse.x = source.box.x - 2f; corpse.y = 880f - corpse.height; corpse.vx = corpse.vy = 0f; corpseManager.transform(corpse, CorpseManager.Transformation.FROZEN); } else if (type == DeathType.ELECTRIC && source != null) { corpse.x = source.box.x + (source.box.width - corpse.width) * 0.5f; corpse.y = 880f - corpse.height; corpse.vx = corpse.vy = 0f; corpseManager.transform(corpse, CorpseManager.Transformation.CONDUCTIVE); corpseManager.transform(corpse, CorpseManager.Transformation.PINNED); } } } private static AudioCue hazardAudioCue(LevelDefinition.Hazard source) { if (source != null && "crusher-impact".equals(source.audioCue)) { return AudioCue.CRUSHER_IMPACT; } if (source != null && "fire-burst".equals(source.audioCue)) { return AudioCue.FIRE_BURST; } if (source != null && "projectile-hit".equals(source.audioCue)) { return AudioCue.PROJECTILE_HIT; } if (source != null && "explosion-blast".equals(source.audioCue)) { return AudioCue.EXPLOSION_BLAST; } return AudioCue.DEATH_IMPACT; } private void checkGoal() { if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, level.goal)) return; finishLevel(deaths, (long) (levelElapsed * 1000f)); } private void finishLevel(int completedDeaths, long completedTimeMs) { long safeTimeMs = Math.max(0L, completedTimeMs); deaths = Math.max(0, completedDeaths); levelElapsed = safeTimeMs / 1_000f; progress.recordCompletion(level.number, deaths, safeTimeMs); completionResult = CompletionResult.create( level, deaths, safeTimeMs, progress.bestDeaths(level.number), progress.bestTimeMs(level.number), levelIndex + 1 < LevelCatalog.all().size() ); player.vx = player.vy = 0f; screen = Screen.COMPLETE; audioEngine.pause(); pointers.clear(); deriveControls(); emitHaptic(HapticFeedbackConstants.LONG_PRESS); audioEngine.play(AudioCue.GOAL_VOICE); refreshAccessibilityDescription(true); } private RectF playerRect() { return new RectF(player.x, player.y, player.x + PLAYER_WIDTH, player.y + PLAYER_HEIGHT); } private static RectF corpseRect(Body corpse) { return new RectF(corpse.x, corpse.y, corpse.x + corpse.width, corpse.y + corpse.height); } private static RectF rect(LevelDefinition.Box box) { return new RectF(box.x, box.y, box.right(), box.bottom()); } private static boolean intersects(float x, float y, float width, float height, LevelDefinition.Box box) { return GameMath.intersects(x, y, width, height, box.x, box.y, box.width, box.height); } @Override protected void onDraw(Canvas canvas) { long drawStartedAt = System.nanoTime(); super.onDraw(canvas); updateViewport(getWidth(), getHeight()); paint.setShader(outsideGradient); canvas.drawRect(0, 0, getWidth(), getHeight(), paint); paint.setShader(null); canvas.save(); canvas.translate(canvasOffsetX, canvasOffsetY); canvas.scale(canvasScale, canvasScale); if (screen == Screen.LEVELS) { drawLevelSelect(canvas); } else if (screen == Screen.SETTINGS) { drawSettings(canvas); } else { drawGame(canvas); if (screen == Screen.COMPLETE) drawComplete(canvas); else if (userPaused) drawPause(canvas); 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 + cameraShakeX, -cameraY + cameraShakeY); drawWorldBackdrop(canvas); drawPlatforms(canvas); drawSwitches(canvas); drawHazards(canvas); drawDoors(canvas); drawGoal(canvas); for (Body corpse : corpses) drawCorpse(canvas, corpse); VolunteerRig.Skin skin = VolunteerRig.Skin.forLevel(level.number); if (player.alive) { VolunteerRig.Motion motion = VolunteerRig.motionFor(characterStateMachine.state()); 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); 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); paint.setColor(Color.argb(70, 255, 222, 170)); canvas.drawCircle(1560, 170, 88, paint); paint.setColor(Color.argb(35, 236, 214, 190)); for (int i = 0; i < 9; i++) { float x = (i * 280f - (cameraX * 0.16f) % 280f) - 80f; canvas.drawCircle(x, 720, 210, paint); } } private void drawWorldBackdrop(Canvas canvas) { paint.setStyle(Paint.Style.FILL); paint.setColor(Color.rgb(33, 38, 58)); for (float x = -100f; x < level.worldWidth + 400f; x += 420f) { canvas.drawRect(x, 390, x + 80, 880, paint); canvas.drawRect(x + 290, 390, x + 370, 880, paint); RectF arch = new RectF(x, 300, x + 370, 680); stroke.setColor(Color.rgb(50, 54, 73)); stroke.setStrokeWidth(42f); canvas.drawArc(arch, 190, 160, false, stroke); } paint.setColor(Color.argb(35, 255, 238, 194)); canvas.drawRect(0, 870, level.worldWidth, 880, paint); } private void drawPlatforms(Canvas canvas) { for (PlatformState state : platformStates) { RectF box = state.rect(); boolean moving = state.definition.isMoving(); 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(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); for (float x = box.left + 28f; x < box.right; x += 55f) canvas.drawLine(x, box.top + 5, x - 18, box.bottom - 5, stroke); } else if (box.height() > 60f) { stroke.setColor(Color.argb(70, 220, 220, 230)); stroke.setStrokeWidth(3f); for (float y = box.top + 48; y < box.bottom; y += 48) canvas.drawLine(box.left, y, box.right, y, stroke); } } } private void drawSwitches(Canvas canvas) { for (LevelDefinition.Switch item : level.switches) { RectF box = rect(item.box); boolean active = item.type == LevelDefinition.SwitchType.PRESSURE ? switchContact(item) : signalActive(item.signalId); if (item.type == LevelDefinition.SwitchType.PRESSURE) { paint.setColor(Color.rgb(79, 62, 46)); 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); paint.setColor(item.type == LevelDefinition.SwitchType.TIMED ? (active ? Color.rgb(72, 211, 255) : Color.rgb(40, 117, 157)) : (active ? Color.rgb(105, 226, 126) : Color.rgb(174, 83, 72))); canvas.drawCircle(box.centerX(), box.centerY(), Math.min(box.width(), box.height()) * 0.28f, paint); stroke.setColor(Color.rgb(235, 225, 194)); stroke.setStrokeWidth(7f); canvas.drawLine(box.centerX(), box.centerY(), box.centerX() + (active ? 20 : -20), box.top - 28, stroke); if (item.type == LevelDefinition.SwitchType.TIMED && active) { float remaining = puzzleSystem.remainingSeconds(item.signalId); drawWorldLabel(canvas, String.format(Locale.US, "%.1f", remaining), box.centerX(), box.top - 52); } drawWorldLabel(canvas, active ? "ON" : "OFF", box.centerX(), box.bottom + 32); } } } private void drawHazards(Canvas canvas) { for (int index = 0; index < level.hazards.size(); index++) { LevelDefinition.Hazard hazard = level.hazards.get(index); RectF box = rect(hazardRuntimes.get(index).currentBox()); switch (hazard.type) { case SPIKES: paint.setColor(Color.rgb(194, 201, 211)); float spikeWidth = 32f; for (float x = box.left; x < box.right; x += spikeWidth) { path.reset(); path.moveTo(x, box.bottom); path.lineTo(Math.min(x + spikeWidth * 0.5f, box.right), box.top); path.lineTo(Math.min(x + spikeWidth, box.right), box.bottom); path.close(); canvas.drawPath(path, paint); } break; case SAW: drawSaw(canvas, box); break; case FREEZER: paint.setColor(Color.argb(145, 80, 208, 244)); canvas.drawRoundRect(box, 14, 14, paint); stroke.setColor(Color.rgb(211, 249, 255)); stroke.setStrokeWidth(7f); canvas.drawLine(box.centerX(), box.top + 15, box.centerX(), box.bottom - 15, stroke); canvas.drawLine(box.left + 14, box.centerY(), box.right - 14, box.centerY(), stroke); canvas.drawLine(box.left + 23, box.top + 23, box.right - 23, box.bottom - 23, stroke); canvas.drawLine(box.right - 23, box.top + 23, box.left + 23, box.bottom - 23, stroke); break; case ELECTRIC: paint.setColor(Color.argb(80, 74, 219, 255)); canvas.drawRoundRect(box, 14, 14, paint); stroke.setColor(Color.rgb(249, 239, 94)); stroke.setStrokeWidth(10f); path.reset(); path.moveTo(box.left + 10, box.top + 18); path.lineTo(box.centerX() + 8, box.top + 40); path.lineTo(box.centerX() - 12, box.centerY()); path.lineTo(box.right - 8, box.centerY() + 15); path.lineTo(box.centerX(), box.bottom - 10); canvas.drawPath(path, stroke); break; case PIT: paint.setColor(Color.rgb(5, 8, 17)); canvas.drawRect(box, paint); paint.setColor(Color.argb(80, 132, 87, 170)); for (float x = box.left + 30; x < box.right; x += 80) canvas.drawOval(new RectF(x, box.top + 35, x + 30, box.top + 80), paint); break; case CRUSHER: drawCrusher(canvas, box, hazardRuntimes.get(index).active(), hazard.visualStyle); break; case FIRE: drawFire(canvas, box, hazard.visualStyle); break; case PROJECTILE: drawProjectile(canvas, box, hazard.visualStyle); break; case EXPLOSION: drawExplosion(canvas, box, hazardRuntimes.get(index).active(), hazard.visualStyle); break; } } } private void drawCrusher(Canvas canvas, RectF box, boolean active, String visualStyle) { boolean brass = "brass-press".equals(visualStyle); paint.setColor(accessibilitySettings.highContrastEnabled() ? Color.BLACK : (brass ? Color.rgb(91, 66, 38) : Color.rgb(72, 76, 88))); canvas.drawRoundRect(box, 12f, 12f, paint); float jawHeight = active ? box.height() * 0.48f : box.height() * 0.18f; paint.setColor(accessibilitySettings.highContrastEnabled() ? Color.WHITE : (brass ? Color.rgb(211, 157, 67) : Color.rgb(174, 180, 192))); canvas.drawRect(box.left + 8f, box.top + 8f, box.right - 8f, box.top + jawHeight, paint); canvas.drawRect(box.left + 8f, box.bottom - jawHeight, box.right - 8f, box.bottom - 8f, paint); drawWorldLabel(canvas, active ? "CRUSH" : "WAIT", box.centerX(), box.top - 20f); } private void drawFire(Canvas canvas, RectF box, String visualStyle) { boolean blue = "blue-flame".equals(visualStyle); paint.setColor(Color.argb(110, blue ? 54 : 236, blue ? 182 : 90, blue ? 255 : 45)); canvas.drawRoundRect(box, 16f, 16f, paint); float wave = accessibilitySettings.reducedMotionEnabled() ? 0f : (float) Math.sin(levelElapsed * 7f) * 10f; paint.setColor(accessibilitySettings.highContrastEnabled() ? Color.WHITE : (blue ? Color.rgb(116, 229, 255) : Color.rgb(255, 190, 64))); for (float x = box.left + 18f; x < box.right; x += 34f) { path.reset(); path.moveTo(x - 14f, box.bottom - 6f); path.lineTo(x + wave * 0.25f, box.top + 12f); path.lineTo(x + 14f, box.bottom - 6f); path.close(); canvas.drawPath(path, paint); } } private void drawProjectile(Canvas canvas, RectF box, String visualStyle) { boolean clockwork = "clockwork-bolt".equals(visualStyle); paint.setColor(accessibilitySettings.highContrastEnabled() ? Color.WHITE : (clockwork ? Color.rgb(233, 181, 76) : Color.rgb(204, 211, 221))); canvas.drawCircle(box.centerX(), box.centerY(), box.width() * 0.46f, paint); stroke.setColor(accessibilitySettings.highContrastEnabled() ? Color.BLACK : Color.rgb(72, 56, 42)); stroke.setStrokeWidth(5f); canvas.drawLine(box.left + 8f, box.centerY(), box.right - 8f, box.centerY(), stroke); canvas.drawLine(box.centerX(), box.top + 8f, box.centerX(), box.bottom - 8f, stroke); } private void drawExplosion(Canvas canvas, RectF box, boolean active, String visualStyle) { boolean steam = "steam-blast".equals(visualStyle); float pulse = accessibilitySettings.reducedMotionEnabled() ? 0f : (float) Math.sin(levelElapsed * 5f) * 8f; paint.setColor(Color.argb(active ? 95 : 28, steam ? 195 : 255, steam ? 220 : 124, steam ? 231 : 52)); canvas.drawCircle(box.centerX(), box.centerY(), Math.max(12f, box.width() * (active ? 0.47f : 0.2f) + pulse), paint); stroke.setColor(accessibilitySettings.highContrastEnabled() ? Color.WHITE : Color.rgb(255, 218, 118)); stroke.setStrokeWidth(7f); canvas.drawLine(box.left + 12f, box.top + 12f, box.right - 12f, box.bottom - 12f, stroke); canvas.drawLine(box.right - 12f, box.top + 12f, box.left + 12f, box.bottom - 12f, stroke); drawWorldLabel(canvas, active ? "BLAST" : "REST", box.centerX(), box.top - 20f); } private void drawSaw(Canvas canvas, RectF box) { float angle = levelElapsed * 4.5f; canvas.save(); canvas.rotate((float) Math.toDegrees(angle), box.centerX(), box.centerY()); paint.setColor(Color.rgb(200, 205, 214)); int teeth = 14; for (int i = 0; i < teeth; i++) { float a = (float) (i * Math.PI * 2 / teeth); float cx = box.centerX() + (float) Math.cos(a) * box.width() * 0.48f; float cy = box.centerY() + (float) Math.sin(a) * box.height() * 0.48f; canvas.drawRect(cx - 7, cy - 13, cx + 7, cy + 13, paint); } canvas.drawCircle(box.centerX(), box.centerY(), box.width() * 0.38f, paint); paint.setColor(Color.rgb(82, 87, 100)); canvas.drawCircle(box.centerX(), box.centerY(), box.width() * 0.12f, paint); canvas.restore(); } private void drawDoors(Canvas canvas) { for (LevelDefinition.Door door : level.doors) { RectF box = rect(door.box); boolean open = doorOpen(door); if (open) { stroke.setColor(Color.argb(90, 113, 238, 155)); stroke.setStrokeWidth(8f); canvas.drawRoundRect(box, 12, 12, stroke); paint.setColor(Color.argb(45, 113, 238, 155)); canvas.drawRoundRect(box, 12, 12, paint); } else { paint.setColor(Color.rgb(74, 58, 53)); canvas.drawRoundRect(box, 9, 9, paint); paint.setColor(Color.rgb(181, 137, 62)); for (float y = box.top + 24; y < box.bottom; y += 44) canvas.drawRect(box.left + 7, y, box.right - 7, y + 10, paint); 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); } } private void drawGoal(Canvas canvas) { RectF box = rect(level.goal); 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, Body corpse) { canvas.save(); float cx = corpse.x + corpse.width * 0.5f; float cy = corpse.y + corpse.height * 0.5f; if (!corpse.frozen) canvas.rotate(90, cx, cy); 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(); } 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, 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, hudStatsText(), 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); float controlScale = controlSettings.controlScale(); drawControlCircle(canvas, 145, 925, 92 * controlScale, "‹", leftPressed); drawControlCircle(canvas, 365, 925, 92 * controlScale, "›", rightPressed); drawControlCircle(canvas, 1760, 900, 116 * controlScale, "▲", jumpPressed); if (!player.alive) { drawCenteredText(canvas, strings.respawning(), VIEW_WIDTH * 0.5f, 235, 38, Color.rgb(250, 222, 153)); } } 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(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(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(), active ? Color.rgb(34, 36, 45) : Color.WHITE); } private void drawLevelSelect(Canvas canvas) { drawSky(canvas); drawCenteredText(canvas, strings.projectTitle(), VIEW_WIDTH * 0.5f, 120, 64, Color.rgb(248, 212, 126)); drawCenteredText(canvas, strings.tagline(), VIEW_WIDTH * 0.5f, 177, 29, Color.rgb(225, 226, 233)); int unlocked = progress.unlockedCount(); float startX = 110f; float cardW = 310f; float cardH = 250f; float gapX = 65f; for (int i = levelPage.firstIndex(); i < levelPage.endExclusive(); i++) { int localIndex = i - levelPage.firstIndex(); int col = localIndex % 5; int row = localIndex / 5; float left = startX + col * (cardW + gapX); float top = 260f + row * 310f; RectF card = new RectF(left, top, left + cardW, top + cardH); boolean enabled = i < unlocked; boolean complete = progress.isCompleted(i + 1); 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); 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(), top + 124, 28, 20, cardW - 28, enabled ? Color.WHITE : Color.rgb(111, 114, 125)); if (complete) { int bestDeaths = progress.bestDeaths(i + 1); long bestTime = progress.bestTimeMs(i + 1); drawCenteredFittedText(canvas, strings.levelBestStats(bestDeaths, bestTime), card.centerX(), top + 185, 21, 17, cardW - 24, Color.rgb(138, 231, 163)); } else if (enabled) { 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); if (levelPage.hasPrevious()) { drawHudButton(canvas, new RectF(50, 920, 330, 1010), strings.previousPage(), false); } if (levelPage.hasNext()) { drawHudButton(canvas, new RectF(1590, 920, 1870, 1010), strings.nextPage(), false); } drawCenteredText(canvas, strings.pageIndicator(levelPage.pageIndex() + 1, levelPage.pageCount()), VIEW_WIDTH * 0.5f, 965, 25, Color.rgb(248, 212, 126)); drawCenteredText(canvas, strings.levelSelectFooter(), VIEW_WIDTH * 0.5f, 1042, 22, Color.argb(190, 230, 230, 236)); } private void drawSettings(Canvas canvas) { drawSky(canvas); drawCenteredText(canvas, strings.settingsTitle(), VIEW_WIDTH * 0.5f, 145, 64, Color.rgb(248, 212, 126)); drawCenteredText(canvas, strings.settingsInstruction(), VIEW_WIDTH * 0.5f, 205, 27, Color.rgb(225, 226, 233)); drawText(canvas, strings.languageSetting(), 1_370f, 126f, 27f, Color.WHITE, true); drawHudButton(canvas, new RectF(1580, 70, 1850, 160), strings.languageName(), false); drawSettingRow(canvas, new RectF(420, 280, 1500, 410), strings.controlSizeSetting(), strings.controlSizeValue(controlSettings.controlSizeIndex())); drawSettingRow(canvas, new RectF(420, 450, 1500, 580), strings.controlOpacitySetting(), 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())); 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); } 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(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) { paint.setColor(Color.argb(218, 8, 12, 23)); canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint); drawCenteredText(canvas, strings.relicClaimed(), VIEW_WIDTH * 0.5f, 205, 64, Color.rgb(250, 211, 111)); drawCenteredText(canvas, strings.levelTitle(level.number), VIEW_WIDTH * 0.5f, 263, 34, Color.WHITE); CompletionResult result = completionResult; if (result != null) { drawCenteredText(canvas, strings.currentResultLine(result.currentDeaths(), result.currentTimeMs()), VIEW_WIDTH * 0.5f, 380, 34, Color.WHITE); drawCenteredText(canvas, strings.bestResultLine(result.bestDeaths(), result.bestTimeMs()), VIEW_WIDTH * 0.5f, 437, 31, Color.rgb(128, 229, 153)); drawCenteredText(canvas, strings.targetsResultLine(result.targetDeaths(), result.targetTimeMs()), VIEW_WIDTH * 0.5f, 510, 28, Color.rgb(210, 211, 218)); drawCenteredText(canvas, nextGoalText(result), VIEW_WIDTH * 0.5f, 585, 31, Color.rgb(250, 211, 111)); } drawHudButton(canvas, new RectF(345, 735, 755, 850), strings.levelSelect(), false); drawHudButton(canvas, new RectF(775, 735, 1145, 850), strings.replay(), false); if (levelIndex + 1 < LevelCatalog.all().size()) { drawHudButton(canvas, new RectF(1165, 735, 1575, 850), strings.nextLevel(), true); } } private String nextGoalText(CompletionResult result) { switch (result.nextGoal()) { case TARGET_DEATHS: return strings.nextGoalDeaths(result.nextGoalValue()); case TARGET_TIME: return strings.nextGoalTime(result.nextGoalValue()); case NEXT_LEVEL: return strings.nextGoalLevel(result.nextGoalValue()); case PERSONAL_BEST: default: return strings.nextGoalPersonalBest(); } } private void drawOnboarding(Canvas canvas) { RectF card = new RectF(535, 175, 1385, 375); paint.setColor(Color.argb(232, 16, 22, 37)); canvas.drawRoundRect(card, 24, 24, paint); stroke.setColor(Color.rgb(226, 169, 63)); stroke.setStrokeWidth(4f); canvas.drawRoundRect(card, 24, 24, stroke); String title; String message; String progressLabel; switch (onboardingFlow.step()) { case MOVEMENT: title = strings.tutorialMovementTitle(); message = strings.tutorialMovementBody(); progressLabel = strings.tutorialProgress(1, 3); break; case MULTITOUCH_JUMP: title = strings.tutorialJumpTitle(); message = strings.tutorialJumpBody(); progressLabel = strings.tutorialProgress(2, 3); break; case CORPSES_ARE_TOOLS: default: title = strings.tutorialCorpseTitle(); message = strings.tutorialCorpseBody(); progressLabel = strings.tutorialProgress(3, 3); break; } drawFittedText(canvas, title, 575, 230, 30, 21, 740, Color.rgb(250, 211, 111), true); drawFittedText(canvas, message, 575, 278, 27, 18, 740, Color.WHITE, false); drawText(canvas, progressLabel, 575, 337, 22, Color.rgb(180, 185, 198), false); drawHudButton(canvas, new RectF(965, 298, 1125, 355), strings.tutorialSkip(), false); drawHudButton(canvas, new RectF(1145, 298, 1345, 355), onboardingFlow.step() == OnboardingFlow.Step.CORPSES_ARE_TOOLS ? strings.tutorialDone() : strings.tutorialNext(), true); } private String onboardingTitle() { switch (onboardingFlow.step()) { case MOVEMENT: return strings.tutorialMovementTitle(); case MULTITOUCH_JUMP: return strings.tutorialJumpTitle(); case CORPSES_ARE_TOOLS: default: return strings.tutorialCorpseTitle(); } } private String onboardingBody() { switch (onboardingFlow.step()) { case MOVEMENT: return strings.tutorialMovementBody(); case MULTITOUCH_JUMP: return strings.tutorialJumpBody(); case CORPSES_ARE_TOOLS: default: return strings.tutorialCorpseBody(); } } private void drawPause(Canvas canvas) { paint.setColor(Color.argb(218, 8, 12, 23)); canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint); drawCenteredText(canvas, strings.paused(), VIEW_WIDTH * 0.5f, 350, 68, Color.rgb(248, 212, 126)); drawHudButton(canvas, new RectF(610, 485, 1310, 610), strings.resume(), true); drawHudButton(canvas, new RectF(610, 650, 1310, 775), strings.levelSelect(), false); } private void drawHudButton(Canvas canvas, RectF box, String label, boolean primary) { 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(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) { drawCenteredText(canvas, label, x, y, 24, Color.WHITE); } 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(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); } private void drawFittedText(Canvas canvas, String text, float x, float baseline, float preferredSize, float minimumSize, float maximumWidth, int color, boolean bold) { float size = fittedTextSize(text, preferredSize, minimumSize, maximumWidth, bold); drawText(canvas, text, x, baseline, size, color, bold); } private void drawCenteredFittedText(Canvas canvas, String text, float x, float baseline, float preferredSize, float minimumSize, float maximumWidth, int color) { float size = fittedTextSize(text, preferredSize, minimumSize, maximumWidth, true); drawCenteredText(canvas, text, x, baseline, size, color); } private float fittedTextSize(String text, float preferredSize, float minimumSize, float maximumWidth, boolean bold) { paint.setTextSize(preferredSize); paint.setTypeface(android.graphics.Typeface.create("sans", bold ? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL)); float measured = paint.measureText(text); if (measured <= maximumWidth || measured <= 0f) return preferredSize; return Math.max(minimumSize, preferredSize * maximumWidth / measured); } 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(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); } private static int withAlpha(int color, float alpha) { 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())); int action = event.getActionMasked(); if (screen == Screen.LEVELS) { if (action == MotionEvent.ACTION_UP) { performClick(); handleLevelSelectTap(point.x, point.y); } return true; } if (screen == Screen.SETTINGS) { if (action == MotionEvent.ACTION_UP) { performClick(); handleSettingsTap(point.x, point.y); } return true; } if (screen == Screen.COMPLETE) { if (action == MotionEvent.ACTION_UP) { performClick(); handleCompleteTap(point.x, point.y); } return true; } if (userPaused) { if (action == MotionEvent.ACTION_UP) { performClick(); handlePauseTap(point.x, point.y); } return true; } if (onboardingFlow.isVisible() && action == MotionEvent.ACTION_UP && handleOnboardingTap(point.x, point.y)) { performClick(); pointers.clear(); deriveControls(); return true; } if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN) { if (contains(1608, 26, 1770, 112, point.x, point.y)) { restartLevel(); return true; } if (contains(1790, 26, 1892, 112, point.x, point.y)) { userPaused = true; audioEngine.pause(); pointers.clear(); deriveControls(); refreshAccessibilityDescription(true); return true; } } rebuildPointers(event); deriveControls(); return true; } @Override public boolean performClick() { super.performClick(); return true; } private void rebuildPointers(MotionEvent event) { pointers.clear(); int action = event.getActionMasked(); int releasedIndex = (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) ? event.getActionIndex() : -1; if (action == MotionEvent.ACTION_CANCEL) return; for (int i = 0; i < event.getPointerCount(); i++) { if (i == releasedIndex) continue; PointF point = toCanvasPoint(event.getX(i), event.getY(i)); pointers.put(event.getPointerId(i), point); } } private void deriveControls() { boolean wasJumpPressed = jumpPressed; leftPressed = false; rightPressed = false; jumpPressed = false; for (int i = 0; i < pointers.size(); i++) { PointF point = pointers.valueAt(i); float controlScale = controlSettings.controlScale(); if (distance(point.x, point.y, 145, 925) <= 115f * controlScale) leftPressed = true; if (distance(point.x, point.y, 365, 925) <= 115f * controlScale) rightPressed = true; if (distance(point.x, point.y, 1760, 900) <= 145f * controlScale) jumpPressed = true; } if (jumpPressed && !wasJumpPressed && player.alive) player.jumpBuffer = JUMP_BUFFER_SECONDS; } private void handleLevelSelectTap(float x, float y) { if (contains(1560, 70, 1850, 160, x, y)) { screen = Screen.SETTINGS; emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); refreshAccessibilityDescription(true); return; } if (levelPage.hasPrevious() && contains(50, 920, 330, 1010, x, y)) { levelPage.previous(); emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); refreshAccessibilityDescription(true); return; } if (levelPage.hasNext() && contains(1590, 920, 1870, 1010, x, y)) { levelPage.next(); emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); refreshAccessibilityDescription(true); return; } float startX = 110f; float cardW = 310f; float cardH = 250f; float gapX = 65f; for (int i = levelPage.firstIndex(); i < levelPage.endExclusive(); i++) { int localIndex = i - levelPage.firstIndex(); int col = localIndex % 5; int row = localIndex / 5; float left = startX + col * (cardW + gapX); float top = 260f + row * 310f; if (contains(left, top, left + cardW, top + cardH, x, y) && i < progress.unlockedCount()) { emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); startLevel(i); return; } } } private void handleCompleteTap(float x, float y) { if (contains(345, 735, 755, 850, x, y)) levelsFromResult(); else if (contains(775, 735, 1145, 850, x, y)) replayFromResult(); else if (contains(1165, 735, 1575, 850, x, y)) nextLevelFromResult(); } private boolean handleOnboardingTap(float x, float y) { if (contains(965, 298, 1125, 355, x, y)) { onboardingFlow.skip(); onboardingStore.markComplete(); invalidate(); return true; } if (contains(1145, 298, 1345, 355, x, y)) { advanceOnboarding(); return true; } return false; } private void advanceOnboarding() { onboardingFlow.next(); if (onboardingFlow.isComplete()) onboardingStore.markComplete(); } private void levelsFromResult() { if (screen != Screen.COMPLETE) return; levelPage.selectIndex(levelIndex); screen = Screen.LEVELS; pointers.clear(); deriveControls(); refreshAccessibilityDescription(true); } private void replayFromResult() { if (screen == Screen.COMPLETE) restartLevel(); } private void nextLevelFromResult() { if (screen == Screen.COMPLETE && levelIndex + 1 < LevelCatalog.all().size()) { startLevel(levelIndex + 1); } } private void handlePauseTap(float x, float y) { if (contains(610, 485, 1310, 610, x, y)) userPaused = false; else if (contains(610, 650, 1310, 775, x, y)) { userPaused = false; screen = Screen.LEVELS; audioEngine.pause(); } refreshAccessibilityDescription(true); } private void handleSettingsTap(float x, float 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(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); } else if (contains(420, 280, 1500, 410, x, y)) { updateControlSettings(controlSettings.nextControlSize()); emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); } else if (contains(420, 450, 1500, 580, x, y)) { updateControlSettings(controlSettings.nextControlOpacity()); emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); } 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); } } private void emitHaptic(int feedbackConstant) { if (controlSettings.hapticsEnabled()) performHapticFeedback(feedbackConstant); } private PointF toCanvasPoint(float x, float y) { 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; } private static float distance(float ax, float ay, float bx, float by) { float dx = ax - bx; float dy = ay - by; return (float) Math.sqrt(dx * dx + dy * dy); } public void startLevelForAutomation(int oneBasedLevelNumber) { if (oneBasedLevelNumber < 1 || oneBasedLevelNumber > LevelCatalog.all().size()) { throw new IllegalArgumentException("Unknown level: " + oneBasedLevelNumber); } 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(); } public ControlSettings controlSettingsForAutomation() { return controlSettings; } public boolean settingsVisibleForAutomation() { return screen == Screen.SETTINGS; } public boolean levelsVisibleForAutomation() { return screen == Screen.LEVELS; } public boolean onboardingVisibleForAutomation() { return screen == Screen.PLAYING && onboardingFlow.isVisible(); } public OnboardingFlow.Step onboardingStepForAutomation() { return onboardingFlow.step(); } public void resetOnboardingForAutomation() { onboardingStore.resetForAutomation(); onboardingFlow = OnboardingFlow.firstRun(false); invalidate(); } public void advanceOnboardingForAutomation() { advanceOnboarding(); } public void skipOnboardingForAutomation() { onboardingFlow.skip(); onboardingStore.markComplete(); invalidate(); } public void resetControlSettingsForAutomation() { updateControlSettings(ControlSettings.defaults()); } public void selectNextControlSizeForAutomation() { updateControlSettings(controlSettings.nextControlSize()); } public void selectNextControlOpacityForAutomation() { updateControlSettings(controlSettings.nextControlOpacity()); } public void toggleHapticsForAutomation() { updateControlSettings(controlSettings.toggleHaptics()); } public void finishLevelForAutomation(int completedDeaths, long completedTimeMs) { if (screen != Screen.PLAYING || level == null) { throw new IllegalStateException("A level must be playing before it can finish"); } finishLevel(completedDeaths, completedTimeMs); } public CompletionResult completionResultForAutomation() { return completionResult; } public boolean resultVisibleForAutomation() { return screen == Screen.COMPLETE; } public boolean levelCompletedForAutomation(int oneBasedLevelNumber) { return progress.isCompleted(oneBasedLevelNumber); } public void levelsFromResultForAutomation() { levelsFromResult(); } public int levelPageIndexForAutomation() { return levelPage.pageIndex(); } public int levelPageCountForAutomation() { return levelPage.pageCount(); } public void nextLevelPageForAutomation() { levelPage.next(); refreshAccessibilityDescription(true); } public void previousLevelPageForAutomation() { levelPage.previous(); refreshAccessibilityDescription(true); } public void replayFromResultForAutomation() { replayFromResult(); } public void nextLevelFromResultForAutomation() { nextLevelFromResult(); } public GameLanguage languageForAutomation() { return language; } public void resetLanguageForAutomation() { updateLanguage(GameLanguage.EN); } public void selectNextLanguageForAutomation() { updateLanguage(language.next()); } public void setLanguageForAutomation(GameLanguage updated) { updateLanguage(updated); } /** Exact localized copy sources that the current Canvas state renders. */ public List visibleLocalizedTextForAutomation() { ArrayList visible = new ArrayList<>(); if (screen == Screen.LEVELS) { visible.add(strings.projectTitle()); visible.add(strings.tagline()); for (int i = levelPage.firstIndex(); i < levelPage.endExclusive(); i++) { boolean enabled = i < progress.unlockedCount(); visible.add(enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked()); visible.add(strings.levelTitle(i + 1)); if (progress.isCompleted(i + 1)) { visible.add(strings.levelBestStats(progress.bestDeaths(i + 1), progress.bestTimeMs(i + 1))); } else if (enabled) { visible.add(strings.tapToEnter()); } } visible.add(strings.settingsButton()); if (levelPage.hasPrevious()) visible.add(strings.previousPage()); if (levelPage.hasNext()) visible.add(strings.nextPage()); visible.add(strings.pageIndicator(levelPage.pageIndex() + 1, levelPage.pageCount())); visible.add(strings.levelSelectFooter()); } else if (screen == Screen.SETTINGS) { visible.add(strings.settingsTitle()); visible.add(strings.settingsInstruction()); visible.add(strings.languageSetting()); visible.add(strings.languageName()); visible.add(strings.controlSizeSetting()); visible.add(strings.controlSizeValue(controlSettings.controlSizeIndex())); visible.add(strings.controlOpacitySetting()); 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.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, strings.levelTitle(level.number))); visible.add(strings.levelHint(level.number)); visible.add(strings.hudStats(deaths, (long) (levelElapsed * 1000))); visible.add(strings.restart()); if (!player.alive) visible.add(strings.respawning()); if (screen == Screen.COMPLETE) { visible.add(strings.relicClaimed()); visible.add(strings.levelTitle(level.number)); if (completionResult != null) { visible.add(strings.currentResultLine(completionResult.currentDeaths(), completionResult.currentTimeMs())); visible.add(strings.bestResultLine(completionResult.bestDeaths(), completionResult.bestTimeMs())); visible.add(strings.targetsResultLine(completionResult.targetDeaths(), completionResult.targetTimeMs())); visible.add(nextGoalText(completionResult)); } visible.add(strings.levelSelect()); visible.add(strings.replay()); if (levelIndex + 1 < LevelCatalog.all().size()) visible.add(strings.nextLevel()); } else if (userPaused) { visible.add(strings.paused()); visible.add(strings.resume()); visible.add(strings.levelSelect()); } else if (onboardingFlow.isVisible()) { visible.add(onboardingTitle()); visible.add(onboardingBody()); visible.add(strings.tutorialSkip()); visible.add(onboardingFlow.step() == OnboardingFlow.Step.CORPSES_ARE_TOOLS ? strings.tutorialDone() : strings.tutorialNext()); } } return Collections.unmodifiableList(visible); } private void updateLanguage(GameLanguage updated) { language = updated == null ? GameLanguage.EN : updated; strings = GameStrings.forLanguage(language); languageStore.save(language); invalidateHudTextCache(); refreshAccessibilityDescription(false); invalidate(); } public AccessibilitySettings accessibilitySettingsForAutomation() { 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()); } public void selectNextTextSizeForAutomation() { updateAccessibilitySettings(accessibilitySettings.nextTextSize()); } public void toggleHighContrastForAutomation() { updateAccessibilitySettings(accessibilitySettings.toggleHighContrast()); } public void toggleReducedMotionForAutomation() { updateAccessibilitySettings(accessibilitySettings.toggleReducedMotion()); } public float textScaleForAutomation() { return accessibilitySettings.textScale(); } public boolean highContrastForAutomation() { 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(); } public String criticalVisibleStatusForAutomation() { return criticalStatusText(); } public void openSettingsForAutomation() { screen = Screen.SETTINGS; refreshAccessibilityDescription(false); } public void pauseForAutomation() { if (screen == Screen.PLAYING) { userPaused = true; audioEngine.pause(); pointers.clear(); deriveControls(); refreshAccessibilityDescription(false); } } public void showCompleteForAutomation() { if (level != null) { screen = Screen.COMPLETE; audioEngine.pause(); 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); invalidateHudTextCache(); refreshAccessibilityDescription(false); 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; 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; 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 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() { if (screen == Screen.LEVELS) { return AccessibilityScreenDescription.levelSelect(strings, progress.unlockedCount(), LevelCatalog.all().size()); } if (screen == Screen.SETTINGS) { return AccessibilityScreenDescription.settings(strings, controlSettings, accessibilitySettings, audioSettings); } 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 void refreshAccessibilityDescriptionIfStateChanged() { if (updateCriticalStatusCache()) applyAccessibilityDescription(false); } private void refreshAccessibilityDescription(boolean announce) { updateCriticalStatusCache(); applyAccessibilityDescription(announce); } private void applyAccessibilityDescription(boolean announce) { 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; rightPressed = moveRight; jumpPressed = jump; 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 (Body 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.deathType())); } ArrayList motionTimes = new ArrayList<>(platformStates.size()); for (PlatformState state : platformStates) motionTimes.add(state.motionTime); builder.platformMotionTimes(motionTimes); PuzzleSystem.State puzzleState = puzzleSystem.snapshot(); for (Map.Entry entry : puzzleState.toggleSignals().entrySet()) { builder.toggleSignal(entry.getKey(), Boolean.TRUE.equals(entry.getValue())); } for (Map.Entry entry : puzzleState.timedSignals().entrySet()) { builder.timedSignal(entry.getKey(), Math.max(0f, entry.getValue())); } for (Map.Entry 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(); for (HazardRuntime runtime : hazardRuntimes) runtime.update(levelElapsed); deaths = snapshot.deaths(); respawnTimer = snapshot.respawnSeconds(); previousSwitchContact.clear(); previousSwitchContact.putAll(snapshot.previousSwitchContacts()); puzzleSystem.restore(snapshot.toggleSignals(), snapshot.timedSignals(), 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(); characterStateMachine.reset(player.alive, player.onGround, player.vx, player.vy, player.facing); for (GameSessionSnapshot.BodyData savedBody : snapshot.bodies()) { Body corpse = corpseManager.register(savedBody.deathType(), savedBody.x(), savedBody.y()) .size(savedBody.width(), savedBody.height()) .velocity(savedBody.velocityX(), savedBody.velocityY()) .grounded(savedBody.grounded()) .supportIndex(savedBody.supportIndex()); if (savedBody.frozen()) { corpseManager.transform(corpse, CorpseManager.Transformation.FROZEN); } if (savedBody.conductive()) { corpseManager.transform(corpse, CorpseManager.Transformation.CONDUCTIVE); } if (savedBody.pinned()) { corpseManager.transform(corpse, CorpseManager.Transformation.PINNED); } } updateSignals(0f, false); leftPressed = false; rightPressed = false; jumpPressed = false; pointers.clear(); userPaused = true; paused = false; lastFootstepAt = levelElapsed; 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) { for (LevelDefinition.Door door : level.doors) allOpen &= doorOpen(door); } float firstMovingPlatformX = Float.NaN; float firstMovingPlatformY = Float.NaN; boolean firstMovingPlatformPowered = false; boolean firstMovingPlatformMovingRight = false; boolean firstMovingPlatformMovingDown = false; for (PlatformState state : platformStates) { if (state.definition.isMoving()) { firstMovingPlatformX = state.x; firstMovingPlatformY = state.y; firstMovingPlatformPowered = signalActive(state.definition.controlId); firstMovingPlatformMovingRight = state.x > state.previousX; firstMovingPlatformMovingDown = state.y > state.previousY; break; } } boolean playerOnMovingPlatform = player.alive && player.supportIndex >= 0 && player.supportIndex < platformStates.size() && platformStates.get(player.supportIndex).definition.isMoving(); boolean firstHazardActive = false; float firstHazardX = Float.NaN; float firstHazardY = Float.NaN; if (!hazardRuntimes.isEmpty()) { HazardRuntime firstHazard = hazardRuntimes.get(0); LevelDefinition.Box firstHazardBox = firstHazard.currentBox(); firstHazardActive = firstHazard.active(); firstHazardX = firstHazardBox.x; firstHazardY = firstHazardBox.y; } return new AutomationSnapshot( screen == Screen.COMPLETE, player.alive, allOpen, userPaused, leftPressed || rightPressed || jumpPressed, level == null ? 0 : level.number, (long) (levelElapsed * 1000f), player.x, player.y, deaths, corpses.size(), firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform, firstMovingPlatformPowered, firstMovingPlatformMovingRight, firstMovingPlatformMovingDown, corpses.isEmpty() ? Float.NaN : corpses.get(0).x, player.onGround, player.vx, player.vy, firstHazardActive, firstHazardX, firstHazardY ); } public CharacterStateMachine.State characterStateForAutomation() { return characterStateMachine.state(); } public void triggerInteractionForAutomation() { characterStateMachine.beginInteraction(); } public void triggerStunForAutomation() { characterStateMachine.stun(); } public CorpseManager.Diagnostics corpseDiagnosticsForAutomation() { return corpseManager.diagnostics(); } public PuzzleSystem.Diagnostics puzzleDiagnosticsForAutomation() { return puzzleSystem.diagnostics(); } }