2687 lines
117 KiB
Java
2687 lines
117 KiB
Java
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<PointF> 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<Body> corpses = corpseManager.bodies();
|
||
private final ArrayList<PlatformState> platformStates = new ArrayList<>();
|
||
private final ArrayList<HazardRuntime> hazardRuntimes = new ArrayList<>();
|
||
private final Map<String, Boolean> previousSwitchContact = new HashMap<>();
|
||
private final Map<LevelDefinition.Door, DoorController> doorControllers =
|
||
new IdentityHashMap<>();
|
||
private final Map<String, LevelDefinition.Door> 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<DoorController> 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<PuzzleSystem.Input> 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<RectF> solidRects(boolean includeCorpses) {
|
||
ArrayList<RectF> 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<String> visibleLocalizedTextForAutomation() {
|
||
ArrayList<String> 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<String> 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<Float> motionTimes = new ArrayList<>(platformStates.size());
|
||
for (PlatformState state : platformStates) motionTimes.add(state.motionTime);
|
||
builder.platformMotionTimes(motionTimes);
|
||
PuzzleSystem.State puzzleState = puzzleSystem.snapshot();
|
||
for (Map.Entry<String, Boolean> entry : puzzleState.toggleSignals().entrySet()) {
|
||
builder.toggleSignal(entry.getKey(), Boolean.TRUE.equals(entry.getValue()));
|
||
}
|
||
for (Map.Entry<String, Float> entry : puzzleState.timedSignals().entrySet()) {
|
||
builder.timedSignal(entry.getKey(), Math.max(0f, entry.getValue()));
|
||
}
|
||
for (Map.Entry<String, Boolean> entry : previousSwitchContact.entrySet()) {
|
||
builder.previousSwitchContact(entry.getKey(), Boolean.TRUE.equals(entry.getValue()));
|
||
}
|
||
return builder.build();
|
||
}
|
||
|
||
private boolean restoreActiveSession(GameSessionSnapshot snapshot) {
|
||
int index = snapshot.levelNumber() - 1;
|
||
if (index < 0 || index >= LevelCatalog.all().size()) return false;
|
||
LevelDefinition definition = LevelCatalog.get(index);
|
||
if (snapshot.platformMotionTimes().size() != definition.platforms.size()
|
||
|| snapshot.bodies().size() > MAX_CORPSES) return false;
|
||
if (!validSupport(snapshot.player().supportIndex(), definition.platforms.size())) {
|
||
return false;
|
||
}
|
||
for (GameSessionSnapshot.BodyData body : snapshot.bodies()) {
|
||
if (!validSupport(body.supportIndex(), definition.platforms.size())) return false;
|
||
try {
|
||
DeathType.valueOf(body.deathType());
|
||
} catch (IllegalArgumentException unsupportedType) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
startLevel(index);
|
||
levelElapsed = snapshot.elapsedSeconds();
|
||
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();
|
||
}
|
||
}
|