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