Завершить инженерный кандидат Stage 5
This commit is contained in:
+2
-2
@@ -10,8 +10,8 @@ android {
|
||||
applicationId 'com.projectsacrifice.game'
|
||||
minSdk 23
|
||||
targetSdk 34
|
||||
versionCode 8
|
||||
versionName '0.7.0-stage4-content'
|
||||
versionCode 9
|
||||
versionName '0.8.0-stage5-vslice'
|
||||
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class AudioSettingsPersistenceTest {
|
||||
@Test
|
||||
public void independentAudioVolumesSurviveActivityRecreation() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.resetAudioSettingsForAutomation();
|
||||
view.selectNextMusicVolumeForAutomation();
|
||||
view.selectNextEffectsVolumeForAutomation();
|
||||
view.selectNextVoiceVolumeForAutomation();
|
||||
});
|
||||
|
||||
activity.recreate();
|
||||
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
AudioSettings settings = view.audioSettingsForAutomation();
|
||||
assertEquals(1f, settings.musicVolume(), 0f);
|
||||
assertEquals(0f, settings.effectsVolume(), 0f);
|
||||
assertEquals(0f, settings.voiceVolume(), 0f);
|
||||
view.openSettingsForAutomation();
|
||||
assertTrue(view.visibleLocalizedTextForAutomation().contains("Audio"));
|
||||
assertTrue(view.screenDescriptionForAutomation().contains("Music 100%"));
|
||||
view.resetAudioSettingsForAutomation();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class GameSessionRestoreTest {
|
||||
@Test
|
||||
public void recreationRestoresActivePuzzleBodiesTimersAndPausedSafety() {
|
||||
GameSessionSnapshot[] before = new GameSessionSnapshot[1];
|
||||
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
GameView[] view = new GameView[1];
|
||||
activity.onActivity(current -> {
|
||||
view[0] = current.gameViewForAutomation();
|
||||
view[0].enableDeterministicAutomationClock();
|
||||
view[0].startLevelForAutomation(5);
|
||||
view[0].setAutomationInput(false, true, false);
|
||||
for (int decision = 0; decision < 400
|
||||
&& view[0].automationSnapshot().deaths() == 0; decision++) {
|
||||
view[0].advanceDeterministicAutomation(16L);
|
||||
}
|
||||
assertEquals(1, view[0].automationSnapshot().deaths());
|
||||
assertEquals(1, view[0].automationSnapshot().corpseCount());
|
||||
view[0].setAutomationInput(false, false, false);
|
||||
before[0] = view[0].sessionSnapshotForAutomation();
|
||||
});
|
||||
|
||||
activity.recreate();
|
||||
|
||||
activity.onActivity(current -> {
|
||||
view[0] = current.gameViewForAutomation();
|
||||
GameSessionSnapshot after = view[0].sessionSnapshotForAutomation();
|
||||
assertEquals(before[0].levelNumber(), after.levelNumber());
|
||||
assertEquals(before[0].deaths(), after.deaths());
|
||||
assertEquals(before[0].elapsedSeconds(), after.elapsedSeconds(), 0.001f);
|
||||
assertEquals(before[0].player().x(), after.player().x(), 0.001f);
|
||||
assertEquals(before[0].player().alive(), after.player().alive());
|
||||
assertEquals(before[0].bodies().size(), after.bodies().size());
|
||||
assertEquals(before[0].bodies().get(0).deathType(),
|
||||
after.bodies().get(0).deathType());
|
||||
assertEquals(before[0].platformMotionTimes(), after.platformMotionTimes());
|
||||
assertEquals(before[0].timedSignals(), after.timedSignals());
|
||||
assertTrue(view[0].automationSnapshot().visiblyPaused());
|
||||
assertFalse(view[0].automationSnapshot().inputActive());
|
||||
current.onBackPressed();
|
||||
view[0].enableDeterministicAutomationClock();
|
||||
view[0].advanceDeterministicAutomation(1_000L);
|
||||
assertTrue(view[0].automationSnapshot().playerAlive());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyProgressMigratesInPlaceWithoutLosingCompletion() {
|
||||
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
SharedPreferences legacy = context.getSharedPreferences(
|
||||
"project_sacrifice_progress_v1", Context.MODE_PRIVATE);
|
||||
legacy.edit().clear()
|
||||
.putInt("unlocked", 6)
|
||||
.putBoolean("level_5_completed", true)
|
||||
.putInt("level_5_deaths", 3)
|
||||
.putLong("level_5_time", 42_000L)
|
||||
.commit();
|
||||
|
||||
ProgressStore migrated = new ProgressStore(context);
|
||||
|
||||
assertEquals(ProgressStore.CURRENT_SAVE_VERSION, migrated.saveVersion());
|
||||
assertEquals(6, migrated.unlockedCount());
|
||||
assertTrue(migrated.isCompleted(5));
|
||||
assertEquals(3, migrated.bestDeaths(5));
|
||||
assertEquals(42_000L, migrated.bestTimeMs(5));
|
||||
legacy.edit().clear().commit();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.app.Instrumentation;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class StageFiveDeterminismTest {
|
||||
@Test
|
||||
public void allTenRecordedRoutesReplayThreeSequentialTimesUnderFiveMinutes() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
GameView[] view = new GameView[1];
|
||||
activity.onActivity(current -> {
|
||||
view[0] = current.gameViewForAutomation();
|
||||
view[0].enableDeterministicAutomationClock();
|
||||
});
|
||||
DeterministicDriver driver = new DeterministicDriver(
|
||||
InstrumentationRegistry.getInstrumentation(), view[0]);
|
||||
LevelPlaytestBot bot = new LevelPlaytestBot();
|
||||
DeterministicInputTape[] tapes = new DeterministicInputTape[10];
|
||||
String[] signatures = new String[10];
|
||||
|
||||
tapes[0] = record(bot, driver, EarlyLevelScenarios.levelOne(), 20_000L, 0, 0);
|
||||
signatures[0] = driver.terminalSignature();
|
||||
tapes[1] = record(bot, driver, EarlyLevelScenarios.levelTwo(), 30_000L, 3, 3);
|
||||
signatures[1] = driver.terminalSignature();
|
||||
tapes[2] = record(bot, driver, EarlyLevelScenarios.levelThree(), 25_000L, 1, 1);
|
||||
signatures[2] = driver.terminalSignature();
|
||||
tapes[3] = record(bot, driver, EarlyLevelScenarios.levelFour(), 30_000L, 0, 0);
|
||||
signatures[3] = driver.terminalSignature();
|
||||
BotReplayRecorder levelFive = new BotReplayRecorder(driver);
|
||||
LevelPlaytestBot.Result levelFiveResult = bot.proveLevelFive(levelFive, 25_000L);
|
||||
assertStandardResult(5, levelFiveResult, 1, 1);
|
||||
tapes[4] = levelFive.tape();
|
||||
signatures[4] = driver.terminalSignature();
|
||||
tapes[5] = record(bot, driver, LateLevelScenarios.levelSix(), 30_000L, 1, 1);
|
||||
signatures[5] = driver.terminalSignature();
|
||||
tapes[6] = record(bot, driver, LateLevelScenarios.levelSeven(), 25_000L, 1, 1);
|
||||
signatures[6] = driver.terminalSignature();
|
||||
|
||||
BotReplayRecorder levelEight = new BotReplayRecorder(driver);
|
||||
LevelEightScenario.Result levelEightResult =
|
||||
new LevelEightScenario().completeIntendedRoute(levelEight, 30_000L);
|
||||
assertTrue(levelEightResult.diagnostic(), levelEightResult.completed());
|
||||
assertEquals(0, levelEightResult.deaths());
|
||||
tapes[7] = levelEight.tape();
|
||||
signatures[7] = driver.terminalSignature();
|
||||
|
||||
tapes[8] = record(bot, driver, LateLevelScenarios.levelNine(), 35_000L, 2, 2);
|
||||
signatures[8] = driver.terminalSignature();
|
||||
tapes[9] = record(bot, driver, LateLevelScenarios.levelTen(), 70_000L, 3, 3);
|
||||
signatures[9] = driver.terminalSignature();
|
||||
|
||||
for (int pass = 1; pass <= 3; pass++) {
|
||||
long passStartedAt = System.currentTimeMillis();
|
||||
for (int level = 0; level < tapes.length; level++) {
|
||||
String replayed = tapes[level].play(driver);
|
||||
assertEquals("pass=" + pass + ", level=" + (level + 1)
|
||||
+ ", tape=" + tapes[level].id(),
|
||||
signatures[level], replayed);
|
||||
assertTrue("pass=" + pass + ", level=" + (level + 1),
|
||||
driver.snapshot().completed());
|
||||
}
|
||||
long elapsed = System.currentTimeMillis() - passStartedAt;
|
||||
assertTrue("Replay matrix " + pass + " exceeded 5 minutes: " + elapsed,
|
||||
elapsed < 300_000L);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static DeterministicInputTape record(
|
||||
LevelPlaytestBot bot, DeterministicDriver driver,
|
||||
LevelPlaytestBot.Scenario scenario, long budget,
|
||||
int expectedDeaths, int expectedBodies) {
|
||||
BotReplayRecorder recorder = new BotReplayRecorder(driver);
|
||||
LevelPlaytestBot.Result result = bot.run(recorder, scenario, budget);
|
||||
DeterministicInputTape tape = recorder.tape();
|
||||
assertStandardResult(tape.levelNumber(), result, expectedDeaths, expectedBodies);
|
||||
return tape;
|
||||
}
|
||||
|
||||
private static void assertStandardResult(int level, LevelPlaytestBot.Result result,
|
||||
int expectedDeaths, int expectedBodies) {
|
||||
assertTrue("level=" + level + "; " + result.diagnostic(), result.completed());
|
||||
assertEquals("level=" + level + "; deaths", expectedDeaths, result.deaths());
|
||||
assertEquals("level=" + level + "; bodies", expectedBodies, result.corpseCount());
|
||||
}
|
||||
|
||||
private static final class DeterministicDriver implements LevelPlaytestBot.Driver,
|
||||
DeterministicInputTape.Target {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView gameView;
|
||||
private long simulationClockMillis;
|
||||
private boolean pendingLeft;
|
||||
private boolean pendingRight;
|
||||
private boolean pendingJump;
|
||||
private boolean inputDirty;
|
||||
private GameView.AutomationSnapshot cachedSnapshot;
|
||||
|
||||
DeterministicDriver(Instrumentation instrumentation, GameView gameView) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.gameView = gameView;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
simulationClockMillis = 0L;
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
gameView.enableDeterministicAutomationClock();
|
||||
gameView.startLevelForAutomation(oneBasedLevelNumber);
|
||||
cachedSnapshot = gameView.automationSnapshot();
|
||||
});
|
||||
pendingLeft = pendingRight = pendingJump = false;
|
||||
inputDirty = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInput(boolean left, boolean right, boolean jump) {
|
||||
pendingLeft = left;
|
||||
pendingRight = right;
|
||||
pendingJump = jump;
|
||||
inputDirty = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameView.AutomationSnapshot snapshot() {
|
||||
if (inputDirty) {
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
gameView.setAutomationInput(pendingLeft, pendingRight, pendingJump);
|
||||
cachedSnapshot = gameView.automationSnapshot();
|
||||
});
|
||||
inputDirty = false;
|
||||
}
|
||||
return cachedSnapshot;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void waitForNextDecision(long millis) { advance(millis); }
|
||||
|
||||
@Override
|
||||
public void advance(long milliseconds) {
|
||||
simulationClockMillis += milliseconds;
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
if (inputDirty) {
|
||||
gameView.setAutomationInput(pendingLeft, pendingRight, pendingJump);
|
||||
inputDirty = false;
|
||||
}
|
||||
gameView.advanceDeterministicAutomation(milliseconds);
|
||||
cachedSnapshot = gameView.automationSnapshot();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public long currentTimeMillis() { return simulationClockMillis; }
|
||||
|
||||
@Override
|
||||
public String terminalSignature() {
|
||||
String[] signature = new String[1];
|
||||
instrumentation.runOnMainSync(() -> {
|
||||
if (inputDirty) {
|
||||
gameView.setAutomationInput(pendingLeft, pendingRight, pendingJump);
|
||||
inputDirty = false;
|
||||
}
|
||||
cachedSnapshot = gameView.automationSnapshot();
|
||||
signature[0] = gameView.deterministicStateSignatureForAutomation();
|
||||
});
|
||||
return signature[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class StageFivePerformanceTest {
|
||||
@Test
|
||||
public void levelTenRendererPublishesAndMeetsTheReleaseBudget() throws Exception {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
GameView[] view = new GameView[1];
|
||||
activity.onActivity(current -> {
|
||||
view[0] = current.gameViewForAutomation();
|
||||
view[0].startLevelForAutomation(10);
|
||||
});
|
||||
|
||||
// Activity launch includes landscape rotation and shader warm-up. Cold-start has a
|
||||
// separate release gate; this contract measures steady-state gameplay rendering.
|
||||
Thread.sleep(5_000L);
|
||||
activity.onActivity(current -> view[0].resetPerformanceMetricsForAutomation());
|
||||
Thread.sleep(10_000L);
|
||||
PerformanceMonitor.Snapshot[] measured = new PerformanceMonitor.Snapshot[1];
|
||||
activity.onActivity(current -> measured[0] =
|
||||
view[0].performanceSnapshotForAutomation());
|
||||
PerformanceMonitor.Snapshot metrics = measured[0];
|
||||
assertTrue("At least 180 uninterrupted frames are required; samples="
|
||||
+ metrics.sampleCount(), metrics.sampleCount() >= 180);
|
||||
PerformanceBudget budget = PerformanceBudget.release();
|
||||
assertTrue(metrics.failureSummary(budget), metrics.within(budget));
|
||||
assertTrue("Target FPS must be explicit", budget.targetFramesPerSecond() >= 60);
|
||||
assertTrue("Runtime body cap must fit the published budget",
|
||||
PhysicsConfig.release().maximumBodies() <= budget.maximumBodies());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -47,6 +47,11 @@ final class AccessibilityScreenDescription {
|
||||
return strings.settingsDescription(controls, accessibility);
|
||||
}
|
||||
|
||||
static String settings(GameStrings strings, ControlSettings controls,
|
||||
AccessibilitySettings accessibility, AudioSettings audio) {
|
||||
return strings.settingsDescription(controls, accessibility, audio);
|
||||
}
|
||||
|
||||
static String playing(GameStrings strings, LevelDefinition level, int deaths,
|
||||
String elapsed, String criticalState, boolean paused) {
|
||||
return strings.playingDescription(level.number, deaths, elapsed, criticalState, paused);
|
||||
|
||||
@@ -16,21 +16,29 @@ public final class AccessibilitySettings {
|
||||
|
||||
private final TextSize textSize;
|
||||
private final boolean highContrastEnabled;
|
||||
private final boolean reducedMotionEnabled;
|
||||
|
||||
private AccessibilitySettings(TextSize textSize, boolean highContrastEnabled) {
|
||||
private AccessibilitySettings(TextSize textSize, boolean highContrastEnabled,
|
||||
boolean reducedMotionEnabled) {
|
||||
this.textSize = textSize;
|
||||
this.highContrastEnabled = highContrastEnabled;
|
||||
this.reducedMotionEnabled = reducedMotionEnabled;
|
||||
}
|
||||
|
||||
public static AccessibilitySettings defaults() {
|
||||
return new AccessibilitySettings(TextSize.STANDARD, false);
|
||||
return new AccessibilitySettings(TextSize.STANDARD, false, false);
|
||||
}
|
||||
|
||||
static AccessibilitySettings restore(int textSizeIndex, boolean highContrastEnabled) {
|
||||
return restore(textSizeIndex, highContrastEnabled, false);
|
||||
}
|
||||
|
||||
static AccessibilitySettings restore(int textSizeIndex, boolean highContrastEnabled,
|
||||
boolean reducedMotionEnabled) {
|
||||
TextSize[] values = TextSize.values();
|
||||
TextSize restored = textSizeIndex >= 0 && textSizeIndex < values.length
|
||||
? values[textSizeIndex] : TextSize.STANDARD;
|
||||
return new AccessibilitySettings(restored, highContrastEnabled);
|
||||
return new AccessibilitySettings(restored, highContrastEnabled, reducedMotionEnabled);
|
||||
}
|
||||
|
||||
public String textSizeLabel() {
|
||||
@@ -48,7 +56,7 @@ public final class AccessibilitySettings {
|
||||
public AccessibilitySettings nextTextSize() {
|
||||
TextSize[] values = TextSize.values();
|
||||
return new AccessibilitySettings(values[(textSize.ordinal() + 1) % values.length],
|
||||
highContrastEnabled);
|
||||
highContrastEnabled, reducedMotionEnabled);
|
||||
}
|
||||
|
||||
public boolean highContrastEnabled() {
|
||||
@@ -60,7 +68,15 @@ public final class AccessibilitySettings {
|
||||
}
|
||||
|
||||
public AccessibilitySettings toggleHighContrast() {
|
||||
return new AccessibilitySettings(textSize, !highContrastEnabled);
|
||||
return new AccessibilitySettings(textSize, !highContrastEnabled, reducedMotionEnabled);
|
||||
}
|
||||
|
||||
public boolean reducedMotionEnabled() { return reducedMotionEnabled; }
|
||||
|
||||
public String reducedMotionLabel() { return reducedMotionEnabled ? "On" : "Off"; }
|
||||
|
||||
public AccessibilitySettings toggleReducedMotion() {
|
||||
return new AccessibilitySettings(textSize, highContrastEnabled, !reducedMotionEnabled);
|
||||
}
|
||||
|
||||
int textSizeIndex() {
|
||||
|
||||
@@ -15,7 +15,8 @@ final class AccessibilitySettingsStore {
|
||||
AccessibilitySettings defaults = AccessibilitySettings.defaults();
|
||||
return AccessibilitySettings.restore(
|
||||
preferences.getInt("text_size", defaults.textSizeIndex()),
|
||||
preferences.getBoolean("high_contrast", defaults.highContrastEnabled())
|
||||
preferences.getBoolean("high_contrast", defaults.highContrastEnabled()),
|
||||
preferences.getBoolean("reduced_motion", defaults.reducedMotionEnabled())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +24,7 @@ final class AccessibilitySettingsStore {
|
||||
preferences.edit()
|
||||
.putInt("text_size", settings.textSizeIndex())
|
||||
.putBoolean("high_contrast", settings.highContrastEnabled())
|
||||
.putBoolean("reduced_motion", settings.reducedMotionEnabled())
|
||||
.apply();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Semantic audio events; Android synthesis is an implementation detail. */
|
||||
public enum AudioCue {
|
||||
UI_TAP(Channel.EFFECTS, 1),
|
||||
FOOTSTEP(Channel.EFFECTS, 3),
|
||||
JUMP(Channel.EFFECTS, 2),
|
||||
SWITCH(Channel.EFFECTS, 3),
|
||||
DOOR(Channel.EFFECTS, 2),
|
||||
DEATH_IMPACT(Channel.EFFECTS, 5),
|
||||
DEATH_VOICE(Channel.VOICE, 4),
|
||||
RESPAWN_VOICE(Channel.VOICE, 3),
|
||||
GOAL_VOICE(Channel.VOICE, 3),
|
||||
MUSIC_PULSE(Channel.MUSIC, 4);
|
||||
|
||||
public enum Channel { MUSIC, EFFECTS, VOICE }
|
||||
|
||||
private final Channel channel;
|
||||
private final int variants;
|
||||
|
||||
AudioCue(Channel channel, int variants) {
|
||||
this.channel = channel;
|
||||
this.variants = variants;
|
||||
}
|
||||
|
||||
public Channel channel() { return channel; }
|
||||
public int variants() { return variants; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/** Small deterministic variant chooser with independent no-repeat history per cue. */
|
||||
public final class AudioCueSequencer {
|
||||
private final int[] previous = new int[AudioCue.values().length];
|
||||
private long state;
|
||||
|
||||
public AudioCueSequencer(long seed) {
|
||||
Arrays.fill(previous, -1);
|
||||
state = seed == 0L ? 0x6d2b79f5L : seed;
|
||||
}
|
||||
|
||||
public int nextVariant(AudioCue cue) {
|
||||
if (cue == null || cue.variants() <= 1) return 0;
|
||||
state = state * 6364136223846793005L + 1442695040888963407L;
|
||||
int candidate = (int) Math.floorMod(state >>> 24, cue.variants());
|
||||
int index = cue.ordinal();
|
||||
if (candidate == previous[index]) candidate = (candidate + 1) % cue.variants();
|
||||
previous[index] = candidate;
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Immutable independent volume presets for procedural game audio. */
|
||||
public final class AudioSettings {
|
||||
private static final float[] VOLUMES = {0f, 0.33f, 0.66f, 1f};
|
||||
private static final int DEFAULT_MUSIC = 2;
|
||||
private static final int DEFAULT_EFFECTS = 3;
|
||||
private static final int DEFAULT_VOICE = 3;
|
||||
|
||||
private final int musicPreset;
|
||||
private final int effectsPreset;
|
||||
private final int voicePreset;
|
||||
|
||||
private AudioSettings(int musicPreset, int effectsPreset, int voicePreset) {
|
||||
this.musicPreset = musicPreset;
|
||||
this.effectsPreset = effectsPreset;
|
||||
this.voicePreset = voicePreset;
|
||||
}
|
||||
|
||||
public static AudioSettings defaults() {
|
||||
return new AudioSettings(DEFAULT_MUSIC, DEFAULT_EFFECTS, DEFAULT_VOICE);
|
||||
}
|
||||
|
||||
public static AudioSettings restore(int musicPreset, int effectsPreset, int voicePreset) {
|
||||
return new AudioSettings(valid(musicPreset, DEFAULT_MUSIC),
|
||||
valid(effectsPreset, DEFAULT_EFFECTS), valid(voicePreset, DEFAULT_VOICE));
|
||||
}
|
||||
|
||||
public int musicPreset() { return musicPreset; }
|
||||
public int effectsPreset() { return effectsPreset; }
|
||||
public int voicePreset() { return voicePreset; }
|
||||
public float musicVolume() { return VOLUMES[musicPreset]; }
|
||||
public float effectsVolume() { return VOLUMES[effectsPreset]; }
|
||||
public float voiceVolume() { return VOLUMES[voicePreset]; }
|
||||
|
||||
public AudioSettings nextMusicVolume() {
|
||||
return new AudioSettings(next(musicPreset), effectsPreset, voicePreset);
|
||||
}
|
||||
|
||||
public AudioSettings nextEffectsVolume() {
|
||||
return new AudioSettings(musicPreset, next(effectsPreset), voicePreset);
|
||||
}
|
||||
|
||||
public AudioSettings nextVoiceVolume() {
|
||||
return new AudioSettings(musicPreset, effectsPreset, next(voicePreset));
|
||||
}
|
||||
|
||||
public String volumeLabel(int preset) {
|
||||
return Math.round(VOLUMES[valid(preset, 0)] * 100f) + "%";
|
||||
}
|
||||
|
||||
private static int next(int preset) {
|
||||
return (preset + 1) % VOLUMES.length;
|
||||
}
|
||||
|
||||
private static int valid(int preset, int fallback) {
|
||||
return preset >= 0 && preset < VOLUMES.length ? preset : fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
final class AudioSettingsStore {
|
||||
private static final String FILE = "project_sacrifice_audio_settings_v1";
|
||||
private final SharedPreferences preferences;
|
||||
|
||||
AudioSettingsStore(Context context) {
|
||||
preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
AudioSettings load() {
|
||||
AudioSettings defaults = AudioSettings.defaults();
|
||||
return AudioSettings.restore(
|
||||
preferences.getInt("music", defaults.musicPreset()),
|
||||
preferences.getInt("effects", defaults.effectsPreset()),
|
||||
preferences.getInt("voice", defaults.voicePreset()));
|
||||
}
|
||||
|
||||
void save(AudioSettings settings) {
|
||||
preferences.edit()
|
||||
.putInt("music", settings.musicPreset())
|
||||
.putInt("effects", settings.effectsPreset())
|
||||
.putInt("voice", settings.voicePreset())
|
||||
.apply();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Records the exact production-input waits emitted by a black-box playtest bot. */
|
||||
public final class BotReplayRecorder implements LevelPlaytestBot.Driver {
|
||||
private final LevelPlaytestBot.Driver delegate;
|
||||
private DeterministicInputTape.Builder tape;
|
||||
private boolean left;
|
||||
private boolean right;
|
||||
private boolean jump;
|
||||
|
||||
public BotReplayRecorder(LevelPlaytestBot.Driver delegate) {
|
||||
if (delegate == null) throw new IllegalArgumentException("Driver is required");
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
tape = DeterministicInputTape.builder(oneBasedLevelNumber);
|
||||
left = right = jump = false;
|
||||
delegate.startLevel(oneBasedLevelNumber);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInput(boolean moveLeft, boolean moveRight, boolean jumpPressed) {
|
||||
left = moveLeft;
|
||||
right = moveRight;
|
||||
jump = jumpPressed;
|
||||
delegate.setInput(moveLeft, moveRight, jumpPressed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameView.AutomationSnapshot snapshot() { return delegate.snapshot(); }
|
||||
|
||||
@Override
|
||||
public void waitForNextDecision(long millis) {
|
||||
if (tape == null) throw new IllegalStateException("A level must start before recording");
|
||||
tape.record(left, right, jump, millis);
|
||||
delegate.waitForNextDecision(millis);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long currentTimeMillis() { return delegate.currentTimeMillis(); }
|
||||
|
||||
public DeterministicInputTape tape() {
|
||||
if (tape == null) throw new IllegalStateException("No replay recorded");
|
||||
return tape.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Deterministic, allocation-free camera follow/focus/shake state. */
|
||||
public final class CameraController {
|
||||
public static final float MAX_FOCUS_OFFSET = 420f;
|
||||
public static final float MAX_SHAKE = 24f;
|
||||
|
||||
public static final class Frame {
|
||||
private float x;
|
||||
private float y;
|
||||
private float shakeX;
|
||||
private float shakeY;
|
||||
private float focusOffsetX;
|
||||
private boolean focusActive;
|
||||
|
||||
public float x() { return x; }
|
||||
public float y() { return y; }
|
||||
public float shakeX() { return shakeX; }
|
||||
public float shakeY() { return shakeY; }
|
||||
public float focusOffsetX() { return focusOffsetX; }
|
||||
public boolean focusActive() { return focusActive; }
|
||||
}
|
||||
|
||||
private static final float SHAKE_SECONDS = 0.28f;
|
||||
private final float viewportWidth;
|
||||
private final float viewportHeight;
|
||||
private final Frame frame = new Frame();
|
||||
private float focusX;
|
||||
private float focusY;
|
||||
private float focusRemaining;
|
||||
private float shakeStrength;
|
||||
private float shakeRemaining;
|
||||
private float shakePhase;
|
||||
|
||||
public CameraController(float viewportWidth, float viewportHeight) {
|
||||
if (viewportWidth <= 0f || viewportHeight <= 0f) {
|
||||
throw new IllegalArgumentException("Camera viewport must be positive");
|
||||
}
|
||||
this.viewportWidth = viewportWidth;
|
||||
this.viewportHeight = viewportHeight;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
frame.x = 0f;
|
||||
frame.y = 0f;
|
||||
frame.shakeX = 0f;
|
||||
frame.shakeY = 0f;
|
||||
frame.focusOffsetX = 0f;
|
||||
frame.focusActive = false;
|
||||
focusRemaining = 0f;
|
||||
shakeStrength = 0f;
|
||||
shakeRemaining = 0f;
|
||||
shakePhase = 0f;
|
||||
}
|
||||
|
||||
public void requestFocus(float worldX, float worldY, float durationSeconds) {
|
||||
focusX = worldX;
|
||||
focusY = worldY;
|
||||
focusRemaining = Math.max(0f, durationSeconds);
|
||||
}
|
||||
|
||||
public void impact(float strength) {
|
||||
shakeStrength = Math.max(shakeStrength, Math.min(MAX_SHAKE, Math.max(0f, strength)));
|
||||
shakeRemaining = shakeStrength > 0f ? SHAKE_SECONDS : 0f;
|
||||
}
|
||||
|
||||
public Frame update(float dt, float playerX, float playerY, int facing,
|
||||
float worldWidth, float worldHeight, boolean reducedMotion) {
|
||||
float safeDt = Math.max(0f, dt);
|
||||
focusRemaining = Math.max(0f, focusRemaining - safeDt);
|
||||
frame.focusActive = focusRemaining > 0f;
|
||||
|
||||
float targetX = playerX - 610f + (facing < 0 ? -110f : 110f);
|
||||
float targetY = playerY - viewportHeight * 0.48f;
|
||||
frame.focusOffsetX = 0f;
|
||||
if (frame.focusActive) {
|
||||
frame.focusOffsetX = clamp(focusX - playerX,
|
||||
-MAX_FOCUS_OFFSET, MAX_FOCUS_OFFSET);
|
||||
targetX += frame.focusOffsetX;
|
||||
targetY += clamp(focusY - playerY,
|
||||
-MAX_FOCUS_OFFSET * 0.5f, MAX_FOCUS_OFFSET * 0.5f);
|
||||
}
|
||||
|
||||
float maxX = Math.max(0f, worldWidth - viewportWidth);
|
||||
float maxY = Math.max(0f, worldHeight - viewportHeight);
|
||||
targetX = clamp(targetX, 0f, maxX);
|
||||
targetY = clamp(targetY, 0f, maxY);
|
||||
float horizontalBlend = 1f - (float) Math.exp(-safeDt * 4.5f);
|
||||
float verticalBlend = 1f - (float) Math.exp(-safeDt * 3.1f);
|
||||
frame.x = clamp(frame.x + (targetX - frame.x) * horizontalBlend, 0f, maxX);
|
||||
frame.y = clamp(frame.y + (targetY - frame.y) * verticalBlend, 0f, maxY);
|
||||
|
||||
shakeRemaining = Math.max(0f, shakeRemaining - safeDt);
|
||||
if (reducedMotion || shakeRemaining <= 0f) {
|
||||
frame.shakeX = 0f;
|
||||
frame.shakeY = 0f;
|
||||
if (shakeRemaining <= 0f) shakeStrength = 0f;
|
||||
} else {
|
||||
shakePhase += safeDt * 71f;
|
||||
float decay = shakeRemaining / SHAKE_SECONDS;
|
||||
frame.shakeX = (float) Math.sin(shakePhase) * shakeStrength * decay;
|
||||
frame.shakeY = (float) Math.cos(shakePhase * 1.37f) * shakeStrength * decay * 0.65f;
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
public Frame frame() {
|
||||
return frame;
|
||||
}
|
||||
|
||||
private static float clamp(float value, float minimum, float maximum) {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/** Compressed production-input command stream replayed against a fixed-step target. */
|
||||
public final class DeterministicInputTape {
|
||||
public interface Target {
|
||||
void startLevel(int oneBasedLevelNumber);
|
||||
void setInput(boolean left, boolean right, boolean jump);
|
||||
void advance(long milliseconds);
|
||||
String terminalSignature();
|
||||
}
|
||||
|
||||
public static final class Command {
|
||||
private final boolean left;
|
||||
private final boolean right;
|
||||
private final boolean jump;
|
||||
private final long milliseconds;
|
||||
|
||||
private Command(boolean left, boolean right, boolean jump, long milliseconds) {
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
this.jump = jump;
|
||||
this.milliseconds = milliseconds;
|
||||
}
|
||||
|
||||
public boolean left() { return left; }
|
||||
public boolean right() { return right; }
|
||||
public boolean jump() { return jump; }
|
||||
public long milliseconds() { return milliseconds; }
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
private final int levelNumber;
|
||||
private final ArrayList<Command> commands = new ArrayList<>();
|
||||
|
||||
private Builder(int levelNumber) {
|
||||
if (levelNumber < 1 || levelNumber > 10) {
|
||||
throw new IllegalArgumentException("Unknown level: " + levelNumber);
|
||||
}
|
||||
this.levelNumber = levelNumber;
|
||||
}
|
||||
|
||||
public Builder record(boolean left, boolean right, boolean jump, long milliseconds) {
|
||||
if (milliseconds <= 0L) throw new IllegalArgumentException("Duration must be positive");
|
||||
if (!commands.isEmpty()) {
|
||||
Command previous = commands.get(commands.size() - 1);
|
||||
if (previous.left == left && previous.right == right && previous.jump == jump) {
|
||||
commands.set(commands.size() - 1, new Command(left, right, jump,
|
||||
Math.addExact(previous.milliseconds, milliseconds)));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
commands.add(new Command(left, right, jump, milliseconds));
|
||||
return this;
|
||||
}
|
||||
|
||||
public DeterministicInputTape build() {
|
||||
return new DeterministicInputTape(levelNumber, commands);
|
||||
}
|
||||
}
|
||||
|
||||
private final int levelNumber;
|
||||
private final List<Command> commands;
|
||||
private final String id;
|
||||
|
||||
private DeterministicInputTape(int levelNumber, List<Command> commands) {
|
||||
this.levelNumber = levelNumber;
|
||||
this.commands = Collections.unmodifiableList(new ArrayList<>(commands));
|
||||
id = digest(levelNumber, commands);
|
||||
}
|
||||
|
||||
public static Builder builder(int levelNumber) { return new Builder(levelNumber); }
|
||||
public int levelNumber() { return levelNumber; }
|
||||
public List<Command> commands() { return commands; }
|
||||
public String id() { return id; }
|
||||
|
||||
public String play(Target target) {
|
||||
if (target == null) throw new IllegalArgumentException("Replay target is required");
|
||||
target.startLevel(levelNumber);
|
||||
for (Command command : commands) {
|
||||
target.setInput(command.left, command.right, command.jump);
|
||||
target.advance(command.milliseconds);
|
||||
}
|
||||
return target.terminalSignature();
|
||||
}
|
||||
|
||||
private static String digest(int levelNumber, List<Command> commands) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
digest.update((byte) levelNumber);
|
||||
for (Command command : commands) {
|
||||
digest.update((byte) (command.left ? 1 : 0));
|
||||
digest.update((byte) (command.right ? 1 : 0));
|
||||
digest.update((byte) (command.jump ? 1 : 0));
|
||||
digest.update(Long.toString(command.milliseconds)
|
||||
.getBytes(StandardCharsets.US_ASCII));
|
||||
digest.update((byte) 0xff);
|
||||
}
|
||||
byte[] bytes = digest.digest();
|
||||
StringBuilder hex = new StringBuilder(24);
|
||||
for (int i = 0; i < 12; i++) {
|
||||
hex.append(Character.forDigit((bytes[i] >>> 4) & 0xf, 16));
|
||||
hex.append(Character.forDigit(bytes[i] & 0xf, 16));
|
||||
}
|
||||
return hex.toString();
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", impossible);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Immutable puzzle-critical state used by versioned activity restoration. */
|
||||
public final class GameSessionSnapshot {
|
||||
public static final class PlayerData {
|
||||
private final float x, y, velocityX, velocityY;
|
||||
private final boolean alive, onGround;
|
||||
private final float coyoteSeconds, jumpBufferSeconds;
|
||||
private final int supportIndex, facing;
|
||||
|
||||
public PlayerData(float x, float y, float velocityX, float velocityY,
|
||||
boolean alive, boolean onGround, float coyoteSeconds,
|
||||
float jumpBufferSeconds, int supportIndex, int facing) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.velocityX = velocityX;
|
||||
this.velocityY = velocityY;
|
||||
this.alive = alive;
|
||||
this.onGround = onGround;
|
||||
this.coyoteSeconds = coyoteSeconds;
|
||||
this.jumpBufferSeconds = jumpBufferSeconds;
|
||||
this.supportIndex = supportIndex;
|
||||
this.facing = facing < 0 ? -1 : 1;
|
||||
}
|
||||
|
||||
public float x() { return x; }
|
||||
public float y() { return y; }
|
||||
public float velocityX() { return velocityX; }
|
||||
public float velocityY() { return velocityY; }
|
||||
public boolean alive() { return alive; }
|
||||
public boolean onGround() { return onGround; }
|
||||
public float coyoteSeconds() { return coyoteSeconds; }
|
||||
public float jumpBufferSeconds() { return jumpBufferSeconds; }
|
||||
public int supportIndex() { return supportIndex; }
|
||||
public int facing() { return facing; }
|
||||
}
|
||||
|
||||
public static final class BodyData {
|
||||
private final float x, y, width, height, velocityX, velocityY;
|
||||
private final boolean frozen, conductive, pinned, grounded;
|
||||
private final int supportIndex;
|
||||
private final String deathType;
|
||||
|
||||
public BodyData(float x, float y, float width, float height,
|
||||
float velocityX, float velocityY, boolean frozen,
|
||||
boolean conductive, boolean pinned, boolean grounded,
|
||||
int supportIndex, String deathType) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.velocityX = velocityX;
|
||||
this.velocityY = velocityY;
|
||||
this.frozen = frozen;
|
||||
this.conductive = conductive;
|
||||
this.pinned = pinned;
|
||||
this.grounded = grounded;
|
||||
this.supportIndex = supportIndex;
|
||||
this.deathType = deathType == null ? "SPIKE" : deathType;
|
||||
}
|
||||
|
||||
public float x() { return x; }
|
||||
public float y() { return y; }
|
||||
public float width() { return width; }
|
||||
public float height() { return height; }
|
||||
public float velocityX() { return velocityX; }
|
||||
public float velocityY() { return velocityY; }
|
||||
public boolean frozen() { return frozen; }
|
||||
public boolean conductive() { return conductive; }
|
||||
public boolean pinned() { return pinned; }
|
||||
public boolean grounded() { return grounded; }
|
||||
public int supportIndex() { return supportIndex; }
|
||||
public String deathType() { return deathType; }
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
private int saveVersion = VersionedSessionCodec.CURRENT_VERSION;
|
||||
private final int levelNumber;
|
||||
private float elapsedSeconds;
|
||||
private int deaths;
|
||||
private float respawnSeconds;
|
||||
private PlayerData player = new PlayerData(
|
||||
0f, 0f, 0f, 0f, true, false, 0f, 0f, -1, 1);
|
||||
private final ArrayList<BodyData> bodies = new ArrayList<>();
|
||||
private final ArrayList<Float> platformMotionTimes = new ArrayList<>();
|
||||
private final LinkedHashMap<String, Boolean> toggleSignals = new LinkedHashMap<>();
|
||||
private final LinkedHashMap<String, Float> timedSignals = new LinkedHashMap<>();
|
||||
private final LinkedHashMap<String, Boolean> previousSwitchContacts =
|
||||
new LinkedHashMap<>();
|
||||
|
||||
private Builder(int levelNumber) {
|
||||
this.levelNumber = levelNumber;
|
||||
}
|
||||
|
||||
Builder saveVersion(int version) { saveVersion = version; return this; }
|
||||
public Builder elapsedSeconds(float value) { elapsedSeconds = value; return this; }
|
||||
public Builder deaths(int value) { deaths = value; return this; }
|
||||
public Builder respawnSeconds(float value) { respawnSeconds = value; return this; }
|
||||
public Builder player(PlayerData value) { player = value; return this; }
|
||||
public Builder addBody(BodyData value) { bodies.add(value); return this; }
|
||||
public Builder platformMotionTimes(List<Float> values) {
|
||||
platformMotionTimes.clear();
|
||||
if (values != null) platformMotionTimes.addAll(values);
|
||||
return this;
|
||||
}
|
||||
public Builder toggleSignal(String key, boolean value) {
|
||||
toggleSignals.put(key, value);
|
||||
return this;
|
||||
}
|
||||
public Builder timedSignal(String key, float value) {
|
||||
timedSignals.put(key, value);
|
||||
return this;
|
||||
}
|
||||
public Builder previousSwitchContact(String key, boolean value) {
|
||||
previousSwitchContacts.put(key, value);
|
||||
return this;
|
||||
}
|
||||
public GameSessionSnapshot build() { return new GameSessionSnapshot(this); }
|
||||
}
|
||||
|
||||
private final int saveVersion;
|
||||
private final int levelNumber;
|
||||
private final float elapsedSeconds;
|
||||
private final int deaths;
|
||||
private final float respawnSeconds;
|
||||
private final PlayerData player;
|
||||
private final List<BodyData> bodies;
|
||||
private final List<Float> platformMotionTimes;
|
||||
private final Map<String, Boolean> toggleSignals;
|
||||
private final Map<String, Float> timedSignals;
|
||||
private final Map<String, Boolean> previousSwitchContacts;
|
||||
|
||||
private GameSessionSnapshot(Builder builder) {
|
||||
saveVersion = builder.saveVersion;
|
||||
levelNumber = builder.levelNumber;
|
||||
elapsedSeconds = builder.elapsedSeconds;
|
||||
deaths = builder.deaths;
|
||||
respawnSeconds = builder.respawnSeconds;
|
||||
player = builder.player;
|
||||
bodies = Collections.unmodifiableList(new ArrayList<>(builder.bodies));
|
||||
platformMotionTimes = Collections.unmodifiableList(
|
||||
new ArrayList<>(builder.platformMotionTimes));
|
||||
toggleSignals = Collections.unmodifiableMap(
|
||||
new LinkedHashMap<>(builder.toggleSignals));
|
||||
timedSignals = Collections.unmodifiableMap(new LinkedHashMap<>(builder.timedSignals));
|
||||
previousSwitchContacts = Collections.unmodifiableMap(
|
||||
new LinkedHashMap<>(builder.previousSwitchContacts));
|
||||
}
|
||||
|
||||
public static Builder builder(int levelNumber) { return new Builder(levelNumber); }
|
||||
public int saveVersion() { return saveVersion; }
|
||||
public int levelNumber() { return levelNumber; }
|
||||
public float elapsedSeconds() { return elapsedSeconds; }
|
||||
public int deaths() { return deaths; }
|
||||
public float respawnSeconds() { return respawnSeconds; }
|
||||
public PlayerData player() { return player; }
|
||||
public List<BodyData> bodies() { return bodies; }
|
||||
public List<Float> platformMotionTimes() { return platformMotionTimes; }
|
||||
public Map<String, Boolean> toggleSignals() { return toggleSignals; }
|
||||
public Map<String, Float> timedSignals() { return timedSignals; }
|
||||
public Map<String, Boolean> previousSwitchContacts() { return previousSwitchContacts; }
|
||||
}
|
||||
@@ -79,6 +79,15 @@ public final class GameStrings {
|
||||
public String highContrastValue(boolean enabled) {
|
||||
return hapticsValue(enabled);
|
||||
}
|
||||
public String reducedMotionSetting() { return russian() ? "Меньше движения" : "Reduced motion"; }
|
||||
public String reducedMotionValue(boolean enabled) { return hapticsValue(enabled); }
|
||||
public String audioTitle() { return russian() ? "ЗВУК" : "Audio"; }
|
||||
public String musicSetting() { return russian() ? "Музыка" : "Music"; }
|
||||
public String effectsSetting() { return russian() ? "Эффекты" : "Effects"; }
|
||||
public String voiceSetting() { return russian() ? "Голоса" : "Voice"; }
|
||||
public String volumeValue(AudioSettings settings, int preset) {
|
||||
return settings.volumeLabel(preset);
|
||||
}
|
||||
public String back() { return russian() ? "Назад" : "Back"; }
|
||||
|
||||
public String hudStats(int deaths, long elapsedMilliseconds) {
|
||||
@@ -222,6 +231,12 @@ public final class GameStrings {
|
||||
|
||||
public String settingsDescription(ControlSettings controls,
|
||||
AccessibilitySettings accessibility) {
|
||||
return settingsDescription(controls, accessibility, AudioSettings.defaults());
|
||||
}
|
||||
|
||||
public String settingsDescription(ControlSettings controls,
|
||||
AccessibilitySettings accessibility,
|
||||
AudioSettings audio) {
|
||||
if (russian()) {
|
||||
return "Настройки. Размер управления "
|
||||
+ controlSizeValue(controls.controlSizeIndex())
|
||||
@@ -230,6 +245,11 @@ public final class GameStrings {
|
||||
+ ". Вибрация " + hapticsValue(controls.hapticsEnabled())
|
||||
+ ". Размер текста " + textSizeValue(accessibility.textScale())
|
||||
+ ". Контраст " + highContrastValue(accessibility.highContrastEnabled())
|
||||
+ ". Меньше движения "
|
||||
+ reducedMotionValue(accessibility.reducedMotionEnabled())
|
||||
+ ". Музыка " + volumeValue(audio, audio.musicPreset())
|
||||
+ ". Эффекты " + volumeValue(audio, audio.effectsPreset())
|
||||
+ ". Голоса " + volumeValue(audio, audio.voicePreset())
|
||||
+ ". Назад.";
|
||||
}
|
||||
return "Settings. Control size " + controls.controlSizeLabel()
|
||||
@@ -237,6 +257,10 @@ public final class GameStrings {
|
||||
+ ". Vibration " + controls.hapticsLabel()
|
||||
+ ". Text size " + accessibility.textSizeLabel()
|
||||
+ ". High contrast " + accessibility.highContrastLabel()
|
||||
+ ". Reduced motion " + accessibility.reducedMotionLabel()
|
||||
+ ". Music " + volumeValue(audio, audio.musicPreset())
|
||||
+ ". Effects " + volumeValue(audio, audio.effectsPreset())
|
||||
+ ". Voice " + volumeValue(audio, audio.voicePreset())
|
||||
+ ". Back.";
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.view.WindowInsetsController;
|
||||
import android.view.WindowManager;
|
||||
|
||||
public final class MainActivity extends Activity {
|
||||
private static final String ACTIVE_SESSION_STATE = "active_session_v2";
|
||||
private GameView gameView;
|
||||
|
||||
@Override
|
||||
@@ -17,10 +18,23 @@ public final class MainActivity extends Activity {
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
gameView = new GameView(this);
|
||||
if (savedInstanceState != null) {
|
||||
byte[] encoded = savedInstanceState.getByteArray(ACTIVE_SESSION_STATE);
|
||||
if (encoded != null) gameView.restoreSessionFromActivityState(encoded);
|
||||
}
|
||||
setContentView(gameView);
|
||||
hideSystemUi();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(Bundle outState) {
|
||||
if (gameView != null) {
|
||||
byte[] encoded = gameView.saveSessionForActivityState();
|
||||
if (encoded != null) outState.putByteArray(ACTIVE_SESSION_STATE, encoded);
|
||||
}
|
||||
super.onSaveInstanceState(outState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Published vertical-slice runtime ceilings for the supported 10-level build. */
|
||||
public final class PerformanceBudget {
|
||||
private static final PerformanceBudget RELEASE = new PerformanceBudget(
|
||||
60, 34f, 12f, 20f, 16, 2_500, 2_200, 128L * 1024L * 1024L);
|
||||
|
||||
private final int targetFramesPerSecond;
|
||||
private final float maximumP95FrameIntervalMs;
|
||||
private final float maximumP95UpdateMs;
|
||||
private final float maximumP95DrawMs;
|
||||
private final int maximumBodies;
|
||||
private final int maximumCollisionChecks;
|
||||
private final int maximumDrawPrimitives;
|
||||
private final long maximumUsedMemoryBytes;
|
||||
|
||||
private PerformanceBudget(int targetFramesPerSecond, float maximumP95FrameIntervalMs,
|
||||
float maximumP95UpdateMs, float maximumP95DrawMs,
|
||||
int maximumBodies, int maximumCollisionChecks,
|
||||
int maximumDrawPrimitives, long maximumUsedMemoryBytes) {
|
||||
this.targetFramesPerSecond = targetFramesPerSecond;
|
||||
this.maximumP95FrameIntervalMs = maximumP95FrameIntervalMs;
|
||||
this.maximumP95UpdateMs = maximumP95UpdateMs;
|
||||
this.maximumP95DrawMs = maximumP95DrawMs;
|
||||
this.maximumBodies = maximumBodies;
|
||||
this.maximumCollisionChecks = maximumCollisionChecks;
|
||||
this.maximumDrawPrimitives = maximumDrawPrimitives;
|
||||
this.maximumUsedMemoryBytes = maximumUsedMemoryBytes;
|
||||
}
|
||||
|
||||
public static PerformanceBudget release() { return RELEASE; }
|
||||
public int targetFramesPerSecond() { return targetFramesPerSecond; }
|
||||
public float maximumP95FrameIntervalMs() { return maximumP95FrameIntervalMs; }
|
||||
public float maximumP95UpdateMs() { return maximumP95UpdateMs; }
|
||||
public float maximumP95DrawMs() { return maximumP95DrawMs; }
|
||||
public int maximumBodies() { return maximumBodies; }
|
||||
public int maximumCollisionChecks() { return maximumCollisionChecks; }
|
||||
public int maximumDrawPrimitives() { return maximumDrawPrimitives; }
|
||||
public long maximumUsedMemoryBytes() { return maximumUsedMemoryBytes; }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Allocation-free rolling samples; allocations occur only when a diagnostic snapshot is asked. */
|
||||
public final class PerformanceMonitor {
|
||||
public static final class Snapshot {
|
||||
private final int sampleCount;
|
||||
private final float p95FrameIntervalMs;
|
||||
private final float p95UpdateMs;
|
||||
private final float p95DrawMs;
|
||||
private final int maximumBodies;
|
||||
private final int maximumCollisionChecks;
|
||||
private final int maximumDrawPrimitives;
|
||||
private final long maximumUsedMemoryBytes;
|
||||
|
||||
private Snapshot(int sampleCount, float p95FrameIntervalMs, float p95UpdateMs,
|
||||
float p95DrawMs, int maximumBodies, int maximumCollisionChecks,
|
||||
int maximumDrawPrimitives, long maximumUsedMemoryBytes) {
|
||||
this.sampleCount = sampleCount;
|
||||
this.p95FrameIntervalMs = p95FrameIntervalMs;
|
||||
this.p95UpdateMs = p95UpdateMs;
|
||||
this.p95DrawMs = p95DrawMs;
|
||||
this.maximumBodies = maximumBodies;
|
||||
this.maximumCollisionChecks = maximumCollisionChecks;
|
||||
this.maximumDrawPrimitives = maximumDrawPrimitives;
|
||||
this.maximumUsedMemoryBytes = maximumUsedMemoryBytes;
|
||||
}
|
||||
|
||||
public int sampleCount() { return sampleCount; }
|
||||
public float p95FrameIntervalMs() { return p95FrameIntervalMs; }
|
||||
public float p95UpdateMs() { return p95UpdateMs; }
|
||||
public float p95DrawMs() { return p95DrawMs; }
|
||||
public int maximumBodies() { return maximumBodies; }
|
||||
public int maximumCollisionChecks() { return maximumCollisionChecks; }
|
||||
public int maximumDrawPrimitives() { return maximumDrawPrimitives; }
|
||||
public long maximumUsedMemoryBytes() { return maximumUsedMemoryBytes; }
|
||||
|
||||
public boolean within(PerformanceBudget budget) {
|
||||
return p95FrameIntervalMs <= budget.maximumP95FrameIntervalMs()
|
||||
&& p95UpdateMs <= budget.maximumP95UpdateMs()
|
||||
&& p95DrawMs <= budget.maximumP95DrawMs()
|
||||
&& maximumBodies <= budget.maximumBodies()
|
||||
&& maximumCollisionChecks <= budget.maximumCollisionChecks()
|
||||
&& maximumDrawPrimitives <= budget.maximumDrawPrimitives()
|
||||
&& maximumUsedMemoryBytes <= budget.maximumUsedMemoryBytes();
|
||||
}
|
||||
|
||||
public String failureSummary(PerformanceBudget budget) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
append(result, "frameP95", p95FrameIntervalMs,
|
||||
budget.maximumP95FrameIntervalMs());
|
||||
append(result, "updateP95", p95UpdateMs, budget.maximumP95UpdateMs());
|
||||
append(result, "drawP95", p95DrawMs, budget.maximumP95DrawMs());
|
||||
append(result, "bodies", maximumBodies, budget.maximumBodies());
|
||||
append(result, "collisionChecks", maximumCollisionChecks,
|
||||
budget.maximumCollisionChecks());
|
||||
append(result, "drawPrimitives", maximumDrawPrimitives,
|
||||
budget.maximumDrawPrimitives());
|
||||
append(result, "usedMemory", maximumUsedMemoryBytes,
|
||||
budget.maximumUsedMemoryBytes());
|
||||
return result.length() == 0 ? "within budget" : result.toString();
|
||||
}
|
||||
|
||||
private static void append(StringBuilder result, String name, float actual,
|
||||
float limit) {
|
||||
if (actual <= limit) return;
|
||||
if (result.length() > 0) result.append("; ");
|
||||
result.append(name).append('=')
|
||||
.append(String.format(Locale.US, "%.2f", actual))
|
||||
.append('>').append(String.format(Locale.US, "%.2f", limit));
|
||||
}
|
||||
|
||||
private static void append(StringBuilder result, String name, long actual,
|
||||
long limit) {
|
||||
if (actual <= limit) return;
|
||||
if (result.length() > 0) result.append("; ");
|
||||
result.append(name).append('=').append(actual).append('>').append(limit);
|
||||
}
|
||||
}
|
||||
|
||||
private final float[] frameIntervals;
|
||||
private final float[] updateTimes;
|
||||
private final float[] drawTimes;
|
||||
private int nextIndex;
|
||||
private int sampleCount;
|
||||
private int maximumBodies;
|
||||
private int maximumCollisionChecks;
|
||||
private int maximumDrawPrimitives;
|
||||
private long maximumUsedMemoryBytes;
|
||||
|
||||
public PerformanceMonitor(int windowSize) {
|
||||
if (windowSize < 20) throw new IllegalArgumentException("Window must contain 20+ frames");
|
||||
frameIntervals = new float[windowSize];
|
||||
updateTimes = new float[windowSize];
|
||||
drawTimes = new float[windowSize];
|
||||
}
|
||||
|
||||
public void recordFrame(float intervalMs, float updateMs, float drawMs) {
|
||||
frameIntervals[nextIndex] = finiteNonNegative(intervalMs);
|
||||
updateTimes[nextIndex] = finiteNonNegative(updateMs);
|
||||
drawTimes[nextIndex] = finiteNonNegative(drawMs);
|
||||
nextIndex = (nextIndex + 1) % frameIntervals.length;
|
||||
sampleCount = Math.min(frameIntervals.length, sampleCount + 1);
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
Arrays.fill(frameIntervals, 0f);
|
||||
Arrays.fill(updateTimes, 0f);
|
||||
Arrays.fill(drawTimes, 0f);
|
||||
nextIndex = 0;
|
||||
sampleCount = 0;
|
||||
maximumBodies = 0;
|
||||
maximumCollisionChecks = 0;
|
||||
maximumDrawPrimitives = 0;
|
||||
maximumUsedMemoryBytes = 0L;
|
||||
}
|
||||
|
||||
public void recordComplexity(int bodies, int collisionChecks,
|
||||
int drawPrimitives, long usedMemoryBytes) {
|
||||
maximumBodies = Math.max(maximumBodies, Math.max(0, bodies));
|
||||
maximumCollisionChecks = Math.max(maximumCollisionChecks,
|
||||
Math.max(0, collisionChecks));
|
||||
maximumDrawPrimitives = Math.max(maximumDrawPrimitives, Math.max(0, drawPrimitives));
|
||||
maximumUsedMemoryBytes = Math.max(maximumUsedMemoryBytes, Math.max(0L, usedMemoryBytes));
|
||||
}
|
||||
|
||||
public Snapshot snapshot() {
|
||||
return new Snapshot(sampleCount, percentile(frameIntervals, sampleCount, 0.95f),
|
||||
percentile(updateTimes, sampleCount, 0.95f),
|
||||
percentile(drawTimes, sampleCount, 0.95f), maximumBodies,
|
||||
maximumCollisionChecks, maximumDrawPrimitives, maximumUsedMemoryBytes);
|
||||
}
|
||||
|
||||
private static float percentile(float[] source, int count, float fraction) {
|
||||
if (count <= 0) return 0f;
|
||||
float[] values = Arrays.copyOf(source, count);
|
||||
Arrays.sort(values);
|
||||
int index = Math.min(count - 1, Math.max(0, (int) Math.ceil(count * fraction) - 1));
|
||||
return values[index];
|
||||
}
|
||||
|
||||
private static float finiteNonNegative(float value) {
|
||||
if (Float.isNaN(value) || Float.isInfinite(value) || value < 0f) {
|
||||
throw new IllegalArgumentException("Performance sample must be finite and non-negative");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Immutable release-tuned physics constants shared by realtime and deterministic automation. */
|
||||
public final class PhysicsConfig {
|
||||
private static final PhysicsConfig RELEASE = new PhysicsConfig(
|
||||
1, 1f / 60f, 1_800f, 370f, 880f,
|
||||
0.13f, 0.14f, 0.78f, 16);
|
||||
|
||||
private final int version;
|
||||
private final float fixedStepSeconds;
|
||||
private final float gravity;
|
||||
private final float runSpeed;
|
||||
private final float jumpSpeed;
|
||||
private final float coyoteSeconds;
|
||||
private final float jumpBufferSeconds;
|
||||
private final float respawnSeconds;
|
||||
private final int maximumBodies;
|
||||
|
||||
private PhysicsConfig(int version, float fixedStepSeconds, float gravity,
|
||||
float runSpeed, float jumpSpeed, float coyoteSeconds,
|
||||
float jumpBufferSeconds, float respawnSeconds,
|
||||
int maximumBodies) {
|
||||
this.version = version;
|
||||
this.fixedStepSeconds = fixedStepSeconds;
|
||||
this.gravity = gravity;
|
||||
this.runSpeed = runSpeed;
|
||||
this.jumpSpeed = jumpSpeed;
|
||||
this.coyoteSeconds = coyoteSeconds;
|
||||
this.jumpBufferSeconds = jumpBufferSeconds;
|
||||
this.respawnSeconds = respawnSeconds;
|
||||
this.maximumBodies = maximumBodies;
|
||||
}
|
||||
|
||||
public static PhysicsConfig release() { return RELEASE; }
|
||||
public int version() { return version; }
|
||||
public float fixedStepSeconds() { return fixedStepSeconds; }
|
||||
public float gravity() { return gravity; }
|
||||
public float runSpeed() { return runSpeed; }
|
||||
public float jumpSpeed() { return jumpSpeed; }
|
||||
public float coyoteSeconds() { return coyoteSeconds; }
|
||||
public float jumpBufferSeconds() { return jumpBufferSeconds; }
|
||||
public float respawnSeconds() { return respawnSeconds; }
|
||||
public int maximumBodies() { return maximumBodies; }
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import android.media.AudioManager;
|
||||
import android.media.ToneGenerator;
|
||||
|
||||
/**
|
||||
* Tiny asset-free clockwork score and cue renderer. The semantic cue contract remains testable
|
||||
* without audio hardware; ToneGenerator failures degrade to silence instead of gameplay failure.
|
||||
*/
|
||||
final class ProceduralAudioEngine {
|
||||
private final AudioCueSequencer sequencer = new AudioCueSequencer(0x51ac71f5L);
|
||||
private ToneGenerator music;
|
||||
private ToneGenerator effects;
|
||||
private ToneGenerator voice;
|
||||
private AudioSettings settings;
|
||||
private long lastMusicBeat = Long.MIN_VALUE;
|
||||
private AudioCue lastCue;
|
||||
private int cueCount;
|
||||
private boolean synthesisEnabled = true;
|
||||
|
||||
ProceduralAudioEngine(AudioSettings settings) {
|
||||
updateSettings(settings);
|
||||
}
|
||||
|
||||
void updateSettings(AudioSettings updated) {
|
||||
settings = updated == null ? AudioSettings.defaults() : updated;
|
||||
releaseGenerators();
|
||||
lastMusicBeat = Long.MIN_VALUE;
|
||||
}
|
||||
|
||||
void play(AudioCue cue) {
|
||||
if (cue == null) return;
|
||||
int variant = sequencer.nextVariant(cue);
|
||||
lastCue = cue;
|
||||
cueCount++;
|
||||
ToneGenerator generator = generator(cue.channel());
|
||||
if (generator == null) return;
|
||||
try {
|
||||
generator.startTone(tone(cue, variant), duration(cue, variant));
|
||||
} catch (RuntimeException ignored) {
|
||||
// Audio must never interrupt a puzzle on devices with unavailable output routes.
|
||||
}
|
||||
}
|
||||
|
||||
void updateMusic(float elapsedSeconds, int levelNumber) {
|
||||
long beat = (long) Math.floor(Math.max(0f, elapsedSeconds) / 0.62f);
|
||||
if (beat == lastMusicBeat) return;
|
||||
lastMusicBeat = beat;
|
||||
if (((beat + Math.max(1, levelNumber)) & 3L) != 3L) play(AudioCue.MUSIC_PULSE);
|
||||
}
|
||||
|
||||
void pause() {
|
||||
stop(music);
|
||||
stop(effects);
|
||||
stop(voice);
|
||||
lastMusicBeat = Long.MIN_VALUE;
|
||||
}
|
||||
|
||||
void release() {
|
||||
releaseGenerators();
|
||||
}
|
||||
|
||||
void setSynthesisEnabled(boolean enabled) {
|
||||
synthesisEnabled = enabled;
|
||||
if (!enabled) releaseGenerators();
|
||||
}
|
||||
|
||||
AudioCue lastCue() { return lastCue; }
|
||||
int cueCount() { return cueCount; }
|
||||
|
||||
private ToneGenerator generator(AudioCue.Channel channel) {
|
||||
if (!synthesisEnabled) return null;
|
||||
switch (channel) {
|
||||
case MUSIC:
|
||||
if (music == null) music = create(settings.musicVolume());
|
||||
return music;
|
||||
case VOICE:
|
||||
if (voice == null) voice = create(settings.voiceVolume());
|
||||
return voice;
|
||||
case EFFECTS:
|
||||
default:
|
||||
if (effects == null) effects = create(settings.effectsVolume());
|
||||
return effects;
|
||||
}
|
||||
}
|
||||
|
||||
private static ToneGenerator create(float volume) {
|
||||
if (volume <= 0f) return null;
|
||||
try {
|
||||
return new ToneGenerator(AudioManager.STREAM_MUSIC,
|
||||
Math.max(0, Math.min(100, Math.round(volume * 100f))));
|
||||
} catch (RuntimeException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int tone(AudioCue cue, int variant) {
|
||||
switch (cue) {
|
||||
case UI_TAP: return ToneGenerator.TONE_PROP_ACK;
|
||||
case FOOTSTEP: return variant == 0 ? ToneGenerator.TONE_DTMF_1
|
||||
: variant == 1 ? ToneGenerator.TONE_DTMF_4 : ToneGenerator.TONE_DTMF_7;
|
||||
case JUMP: return variant == 0 ? ToneGenerator.TONE_DTMF_A
|
||||
: ToneGenerator.TONE_DTMF_B;
|
||||
case SWITCH: return variant == 0 ? ToneGenerator.TONE_PROP_BEEP
|
||||
: variant == 1 ? ToneGenerator.TONE_PROP_BEEP2 : ToneGenerator.TONE_PROP_PROMPT;
|
||||
case DOOR: return variant == 0 ? ToneGenerator.TONE_CDMA_CONFIRM
|
||||
: ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD;
|
||||
case DEATH_IMPACT: return ToneGenerator.TONE_DTMF_0 + Math.min(variant, 5);
|
||||
case DEATH_VOICE: return ToneGenerator.TONE_DTMF_C + Math.min(variant, 1);
|
||||
case RESPAWN_VOICE: return variant == 0 ? ToneGenerator.TONE_CDMA_PIP
|
||||
: variant == 1 ? ToneGenerator.TONE_CDMA_CALL_SIGNAL_ISDN_NORMAL
|
||||
: ToneGenerator.TONE_CDMA_NETWORK_BUSY;
|
||||
case GOAL_VOICE: return variant == 0 ? ToneGenerator.TONE_CDMA_CONFIRM
|
||||
: variant == 1 ? ToneGenerator.TONE_CDMA_ABBR_ALERT
|
||||
: ToneGenerator.TONE_CDMA_CALLDROP_LITE;
|
||||
case MUSIC_PULSE:
|
||||
default:
|
||||
return ToneGenerator.TONE_DTMF_1 + Math.min(variant * 2, 8);
|
||||
}
|
||||
}
|
||||
|
||||
private static int duration(AudioCue cue, int variant) {
|
||||
switch (cue) {
|
||||
case FOOTSTEP: return 38 + variant * 7;
|
||||
case JUMP: return 90 + variant * 25;
|
||||
case SWITCH: return 85 + variant * 20;
|
||||
case DOOR: return 180;
|
||||
case DEATH_IMPACT: return 130 + variant * 18;
|
||||
case DEATH_VOICE: return 260 + variant * 45;
|
||||
case RESPAWN_VOICE: return 175 + variant * 35;
|
||||
case GOAL_VOICE: return 320 + variant * 55;
|
||||
case MUSIC_PULSE: return 105 + variant * 15;
|
||||
case UI_TAP:
|
||||
default: return 55;
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseGenerators() {
|
||||
release(music);
|
||||
release(effects);
|
||||
release(voice);
|
||||
music = null;
|
||||
effects = null;
|
||||
voice = null;
|
||||
}
|
||||
|
||||
private static void stop(ToneGenerator generator) {
|
||||
if (generator == null) return;
|
||||
try {
|
||||
generator.stopTone();
|
||||
} catch (RuntimeException ignored) {
|
||||
// Best-effort lifecycle cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
private static void release(ToneGenerator generator) {
|
||||
if (generator == null) return;
|
||||
try {
|
||||
generator.release();
|
||||
} catch (RuntimeException ignored) {
|
||||
// Best-effort lifecycle cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import android.content.SharedPreferences;
|
||||
|
||||
public final class ProgressStore {
|
||||
private static final String FILE = "project_sacrifice_progress_v1";
|
||||
public static final int CURRENT_SAVE_VERSION = 2;
|
||||
private final SharedPreferences preferences;
|
||||
|
||||
public ProgressStore(Context context) {
|
||||
preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE);
|
||||
migrateIfSupported();
|
||||
}
|
||||
|
||||
public int unlockedCount() {
|
||||
@@ -36,6 +38,17 @@ public final class ProgressStore {
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
public int saveVersion() {
|
||||
return preferences.getInt("save_version", 1);
|
||||
}
|
||||
|
||||
private void migrateIfSupported() {
|
||||
int version = preferences.getInt("save_version", 1);
|
||||
if (version >= 1 && version < CURRENT_SAVE_VERSION) {
|
||||
preferences.edit().putInt("save_version", CURRENT_SAVE_VERSION).apply();
|
||||
}
|
||||
}
|
||||
|
||||
private static String key(int levelNumber, String suffix) {
|
||||
return "level_" + levelNumber + "_" + suffix;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
/** Stable compact fingerprint for exact deterministic replay comparisons. */
|
||||
public final class ReplayStateSignature {
|
||||
private ReplayStateSignature() {}
|
||||
|
||||
public static String of(byte[] puzzleState, boolean completed, boolean allDoorsOpen) {
|
||||
if (puzzleState == null) throw new IllegalArgumentException("Puzzle state is required");
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
digest.update((byte) (completed ? 1 : 0));
|
||||
digest.update((byte) (allDoorsOpen ? 1 : 0));
|
||||
digest.update(puzzleState);
|
||||
byte[] bytes = digest.digest();
|
||||
StringBuilder hex = new StringBuilder(32);
|
||||
for (int i = 0; i < 16; i++) {
|
||||
hex.append(Character.forDigit((bytes[i] >>> 4) & 0xf, 16));
|
||||
hex.append(Character.forDigit(bytes[i] & 0xf, 16));
|
||||
}
|
||||
return hex.toString();
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", impossible);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Map;
|
||||
|
||||
/** Bounded binary codec for Android saved-instance-state; no Java serialization is used. */
|
||||
public final class VersionedSessionCodec {
|
||||
private static final int MAGIC = 0x47534e50; // GSNP
|
||||
private static final int MAX_BODIES = 16;
|
||||
private static final int MAX_PLATFORMS = 64;
|
||||
private static final int MAX_MAP_ENTRIES = 128;
|
||||
public static final int CURRENT_VERSION = 2;
|
||||
|
||||
public enum Status { RESTORED, MIGRATED, UNSUPPORTED, CORRUPT }
|
||||
|
||||
public static final class DecodeResult {
|
||||
private final Status status;
|
||||
private final GameSessionSnapshot snapshot;
|
||||
|
||||
private DecodeResult(Status status, GameSessionSnapshot snapshot) {
|
||||
this.status = status;
|
||||
this.snapshot = snapshot;
|
||||
}
|
||||
|
||||
public Status status() { return status; }
|
||||
public GameSessionSnapshot snapshot() { return snapshot; }
|
||||
}
|
||||
|
||||
private VersionedSessionCodec() {}
|
||||
|
||||
public static byte[] encode(GameSessionSnapshot snapshot) {
|
||||
return encodeWithVersion(snapshot, CURRENT_VERSION);
|
||||
}
|
||||
|
||||
static byte[] encodeLegacyV1(GameSessionSnapshot snapshot) {
|
||||
return encodeWithVersion(snapshot, 1);
|
||||
}
|
||||
|
||||
static byte[] encodeVersionForTest(int version) {
|
||||
try {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
DataOutputStream output = new DataOutputStream(bytes);
|
||||
output.writeInt(MAGIC);
|
||||
output.writeInt(version);
|
||||
output.flush();
|
||||
return bytes.toByteArray();
|
||||
} catch (IOException impossible) {
|
||||
throw new IllegalStateException(impossible);
|
||||
}
|
||||
}
|
||||
|
||||
public static DecodeResult decode(byte[] encoded) {
|
||||
if (encoded == null || encoded.length < 8) return result(Status.CORRUPT, null);
|
||||
try {
|
||||
DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded));
|
||||
if (input.readInt() != MAGIC) return result(Status.CORRUPT, null);
|
||||
int version = input.readInt();
|
||||
if (version > CURRENT_VERSION) return result(Status.UNSUPPORTED, null);
|
||||
if (version < 1) return result(Status.CORRUPT, null);
|
||||
|
||||
int levelNumber = input.readInt();
|
||||
if (levelNumber < 1 || levelNumber > 10) return result(Status.CORRUPT, null);
|
||||
GameSessionSnapshot.Builder builder = GameSessionSnapshot.builder(levelNumber)
|
||||
.saveVersion(CURRENT_VERSION)
|
||||
.elapsedSeconds(finiteNonNegative(input.readFloat()))
|
||||
.deaths(bounded(input.readInt(), 0, 10_000))
|
||||
.respawnSeconds(finiteNonNegative(input.readFloat()))
|
||||
.player(readPlayer(input));
|
||||
|
||||
int bodyCount = checkedCount(input.readInt(), MAX_BODIES);
|
||||
for (int i = 0; i < bodyCount; i++) builder.addBody(readBody(input));
|
||||
|
||||
int platformCount = checkedCount(input.readInt(), MAX_PLATFORMS);
|
||||
ArrayList<Float> motionTimes = new ArrayList<>(platformCount);
|
||||
for (int i = 0; i < platformCount; i++) {
|
||||
motionTimes.add(finiteNonNegative(input.readFloat()));
|
||||
}
|
||||
builder.platformMotionTimes(motionTimes);
|
||||
readBooleanMap(input, builder, 0);
|
||||
readFloatMap(input, builder);
|
||||
if (version >= 2) readBooleanMap(input, builder, 1);
|
||||
if (input.available() != 0) return result(Status.CORRUPT, null);
|
||||
GameSessionSnapshot snapshot = builder.build();
|
||||
return result(version == CURRENT_VERSION ? Status.RESTORED : Status.MIGRATED,
|
||||
snapshot);
|
||||
} catch (IOException | IllegalArgumentException failure) {
|
||||
return result(Status.CORRUPT, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] encodeWithVersion(GameSessionSnapshot snapshot, int version) {
|
||||
if (snapshot == null) throw new IllegalArgumentException("Snapshot is required");
|
||||
try {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
DataOutputStream output = new DataOutputStream(bytes);
|
||||
output.writeInt(MAGIC);
|
||||
output.writeInt(version);
|
||||
output.writeInt(snapshot.levelNumber());
|
||||
output.writeFloat(snapshot.elapsedSeconds());
|
||||
output.writeInt(snapshot.deaths());
|
||||
output.writeFloat(snapshot.respawnSeconds());
|
||||
writePlayer(output, snapshot.player());
|
||||
output.writeInt(snapshot.bodies().size());
|
||||
for (GameSessionSnapshot.BodyData body : snapshot.bodies()) writeBody(output, body);
|
||||
output.writeInt(snapshot.platformMotionTimes().size());
|
||||
for (Float time : snapshot.platformMotionTimes()) output.writeFloat(time);
|
||||
writeBooleanMap(output, snapshot.toggleSignals());
|
||||
writeFloatMap(output, snapshot.timedSignals());
|
||||
if (version >= 2) writeBooleanMap(output, snapshot.previousSwitchContacts());
|
||||
output.flush();
|
||||
return bytes.toByteArray();
|
||||
} catch (IOException impossible) {
|
||||
throw new IllegalStateException(impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writePlayer(DataOutputStream output, GameSessionSnapshot.PlayerData data)
|
||||
throws IOException {
|
||||
output.writeFloat(data.x());
|
||||
output.writeFloat(data.y());
|
||||
output.writeFloat(data.velocityX());
|
||||
output.writeFloat(data.velocityY());
|
||||
output.writeBoolean(data.alive());
|
||||
output.writeBoolean(data.onGround());
|
||||
output.writeFloat(data.coyoteSeconds());
|
||||
output.writeFloat(data.jumpBufferSeconds());
|
||||
output.writeInt(data.supportIndex());
|
||||
output.writeInt(data.facing());
|
||||
}
|
||||
|
||||
private static GameSessionSnapshot.PlayerData readPlayer(DataInputStream input)
|
||||
throws IOException {
|
||||
return new GameSessionSnapshot.PlayerData(
|
||||
finite(input.readFloat()), finite(input.readFloat()),
|
||||
finite(input.readFloat()), finite(input.readFloat()),
|
||||
input.readBoolean(), input.readBoolean(),
|
||||
finiteNonNegative(input.readFloat()), finiteNonNegative(input.readFloat()),
|
||||
input.readInt(), input.readInt());
|
||||
}
|
||||
|
||||
private static void writeBody(DataOutputStream output, GameSessionSnapshot.BodyData body)
|
||||
throws IOException {
|
||||
output.writeFloat(body.x());
|
||||
output.writeFloat(body.y());
|
||||
output.writeFloat(body.width());
|
||||
output.writeFloat(body.height());
|
||||
output.writeFloat(body.velocityX());
|
||||
output.writeFloat(body.velocityY());
|
||||
output.writeBoolean(body.frozen());
|
||||
output.writeBoolean(body.conductive());
|
||||
output.writeBoolean(body.pinned());
|
||||
output.writeBoolean(body.grounded());
|
||||
output.writeInt(body.supportIndex());
|
||||
output.writeUTF(body.deathType());
|
||||
}
|
||||
|
||||
private static GameSessionSnapshot.BodyData readBody(DataInputStream input)
|
||||
throws IOException {
|
||||
float x = finite(input.readFloat());
|
||||
float y = finite(input.readFloat());
|
||||
float width = finitePositive(input.readFloat());
|
||||
float height = finitePositive(input.readFloat());
|
||||
float velocityX = finite(input.readFloat());
|
||||
float velocityY = finite(input.readFloat());
|
||||
boolean frozen = input.readBoolean();
|
||||
boolean conductive = input.readBoolean();
|
||||
boolean pinned = input.readBoolean();
|
||||
boolean grounded = input.readBoolean();
|
||||
int support = input.readInt();
|
||||
String deathType = input.readUTF();
|
||||
if (deathType.length() > 24) throw new IllegalArgumentException("Death type too long");
|
||||
return new GameSessionSnapshot.BodyData(x, y, width, height,
|
||||
velocityX, velocityY, frozen, conductive, pinned, grounded,
|
||||
support, deathType);
|
||||
}
|
||||
|
||||
private static void writeBooleanMap(DataOutputStream output, Map<String, Boolean> values)
|
||||
throws IOException {
|
||||
output.writeInt(values.size());
|
||||
for (Map.Entry<String, Boolean> entry : values.entrySet()) {
|
||||
output.writeUTF(entry.getKey());
|
||||
output.writeBoolean(Boolean.TRUE.equals(entry.getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
private static void readBooleanMap(DataInputStream input,
|
||||
GameSessionSnapshot.Builder builder, int destination)
|
||||
throws IOException {
|
||||
int count = checkedCount(input.readInt(), MAX_MAP_ENTRIES);
|
||||
for (int i = 0; i < count; i++) {
|
||||
String key = checkedKey(input.readUTF());
|
||||
boolean value = input.readBoolean();
|
||||
if (destination == 0) builder.toggleSignal(key, value);
|
||||
else builder.previousSwitchContact(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeFloatMap(DataOutputStream output, Map<String, Float> values)
|
||||
throws IOException {
|
||||
output.writeInt(values.size());
|
||||
for (Map.Entry<String, Float> entry : values.entrySet()) {
|
||||
output.writeUTF(entry.getKey());
|
||||
output.writeFloat(entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private static void readFloatMap(DataInputStream input, GameSessionSnapshot.Builder builder)
|
||||
throws IOException {
|
||||
int count = checkedCount(input.readInt(), MAX_MAP_ENTRIES);
|
||||
for (int i = 0; i < count; i++) {
|
||||
builder.timedSignal(checkedKey(input.readUTF()),
|
||||
finiteNonNegative(input.readFloat()));
|
||||
}
|
||||
}
|
||||
|
||||
private static int checkedCount(int value, int maximum) {
|
||||
if (value < 0 || value > maximum) throw new IllegalArgumentException("Invalid count");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String checkedKey(String value) {
|
||||
if (value == null || value.isEmpty() || value.length() > 128) {
|
||||
throw new IllegalArgumentException("Invalid state key");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static int bounded(int value, int minimum, int maximum) {
|
||||
if (value < minimum || value > maximum) throw new IllegalArgumentException("Out of range");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static float finite(float value) {
|
||||
if (Float.isNaN(value) || Float.isInfinite(value)) {
|
||||
throw new IllegalArgumentException("Non-finite value");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static float finiteNonNegative(float value) {
|
||||
value = finite(value);
|
||||
if (value < 0f) throw new IllegalArgumentException("Negative value");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static float finitePositive(float value) {
|
||||
value = finite(value);
|
||||
if (value <= 0f) throw new IllegalArgumentException("Non-positive value");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static DecodeResult result(Status status, GameSessionSnapshot snapshot) {
|
||||
return new DecodeResult(status, snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
|
||||
/** Canvas-only adapter for the normalized volunteer skeleton. */
|
||||
final class VolunteerCanvasRenderer {
|
||||
private static final int FRAME_COUNT = 16;
|
||||
private static final VolunteerRig.Pose[][][][] POSES = buildPoseCache();
|
||||
|
||||
void draw(Canvas canvas, Paint fill, Paint outline, Path path,
|
||||
float x, float y, float width, float height,
|
||||
VolunteerRig.Skin skin, VolunteerRig.Motion motion,
|
||||
float phase, int facing, float alpha, boolean highContrast) {
|
||||
VolunteerRig.Pose pose = cachedPose(skin, motion, phase, facing);
|
||||
int frameColor = highContrast ? Color.WHITE : pose.palette().frame();
|
||||
int shellColor = highContrast ? Color.rgb(255, 214, 77) : pose.palette().shell();
|
||||
int clothColor = highContrast ? Color.WHITE : pose.palette().cloth();
|
||||
int glowColor = highContrast ? Color.BLACK : pose.palette().glow();
|
||||
|
||||
outline.setStyle(Paint.Style.STROKE);
|
||||
outline.setStrokeCap(Paint.Cap.ROUND);
|
||||
outline.setStrokeWidth(Math.max(3f, width * 0.075f));
|
||||
outline.setColor(withAlpha(frameColor, alpha));
|
||||
|
||||
line(canvas, outline, pose, VolunteerRig.Joint.HIPS, VolunteerRig.Joint.CHEST,
|
||||
x, y, width, height);
|
||||
line(canvas, outline, pose, VolunteerRig.Joint.LEFT_SHOULDER,
|
||||
VolunteerRig.Joint.LEFT_HAND, x, y, width, height);
|
||||
line(canvas, outline, pose, VolunteerRig.Joint.RIGHT_SHOULDER,
|
||||
VolunteerRig.Joint.RIGHT_HAND, x, y, width, height);
|
||||
line(canvas, outline, pose, VolunteerRig.Joint.HIPS,
|
||||
VolunteerRig.Joint.LEFT_KNEE, x, y, width, height);
|
||||
line(canvas, outline, pose, VolunteerRig.Joint.LEFT_KNEE,
|
||||
VolunteerRig.Joint.LEFT_FOOT, x, y, width, height);
|
||||
line(canvas, outline, pose, VolunteerRig.Joint.HIPS,
|
||||
VolunteerRig.Joint.RIGHT_KNEE, x, y, width, height);
|
||||
line(canvas, outline, pose, VolunteerRig.Joint.RIGHT_KNEE,
|
||||
VolunteerRig.Joint.RIGHT_FOOT, x, y, width, height);
|
||||
|
||||
float chestX = px(pose, VolunteerRig.Joint.CHEST, x, width);
|
||||
float chestY = py(pose, VolunteerRig.Joint.CHEST, y, height);
|
||||
float hipsX = px(pose, VolunteerRig.Joint.HIPS, x, width);
|
||||
float hipsY = py(pose, VolunteerRig.Joint.HIPS, y, height);
|
||||
float headX = px(pose, VolunteerRig.Joint.HEAD, x, width);
|
||||
float headY = py(pose, VolunteerRig.Joint.HEAD, y, height);
|
||||
|
||||
fill.setColor(withAlpha(clothColor, alpha * 0.86f));
|
||||
path.reset();
|
||||
path.moveTo(chestX - width * 0.22f * facing, chestY + height * 0.04f);
|
||||
path.lineTo(hipsX - width * 0.36f * facing, hipsY + height * 0.17f);
|
||||
path.lineTo(hipsX, hipsY + height * 0.06f);
|
||||
path.close();
|
||||
canvas.drawPath(path, fill);
|
||||
|
||||
fill.setColor(withAlpha(shellColor, alpha));
|
||||
canvas.drawRoundRect(chestX - width * 0.22f, chestY - height * 0.08f,
|
||||
chestX + width * 0.22f, hipsY + height * 0.04f,
|
||||
width * 0.13f, width * 0.13f, fill);
|
||||
canvas.drawCircle(headX, headY, width * 0.22f, fill);
|
||||
|
||||
outline.setStrokeWidth(Math.max(2f, width * 0.045f));
|
||||
canvas.drawCircle(headX, headY, width * 0.22f, outline);
|
||||
canvas.drawRoundRect(chestX - width * 0.22f, chestY - height * 0.08f,
|
||||
chestX + width * 0.22f, hipsY + height * 0.04f,
|
||||
width * 0.13f, width * 0.13f, outline);
|
||||
|
||||
fill.setColor(withAlpha(pose.visorLit() ? glowColor : frameColor,
|
||||
pose.visorLit() ? alpha : alpha * 0.72f));
|
||||
canvas.drawRoundRect(headX - width * 0.14f, headY - height * 0.025f,
|
||||
headX + width * 0.14f, headY + height * 0.025f,
|
||||
width * 0.025f, width * 0.025f, fill);
|
||||
|
||||
drawJoint(canvas, fill, pose, VolunteerRig.Joint.LEFT_SHOULDER,
|
||||
x, y, width, height, frameColor, alpha);
|
||||
drawJoint(canvas, fill, pose, VolunteerRig.Joint.RIGHT_SHOULDER,
|
||||
x, y, width, height, frameColor, alpha);
|
||||
drawJoint(canvas, fill, pose, VolunteerRig.Joint.LEFT_KNEE,
|
||||
x, y, width, height, frameColor, alpha);
|
||||
drawJoint(canvas, fill, pose, VolunteerRig.Joint.RIGHT_KNEE,
|
||||
x, y, width, height, frameColor, alpha);
|
||||
|
||||
if (pose.anchorBolt()) {
|
||||
fill.setColor(withAlpha(glowColor, alpha));
|
||||
canvas.drawCircle(hipsX, hipsY, width * 0.14f, fill);
|
||||
outline.setColor(withAlpha(frameColor, alpha));
|
||||
canvas.drawLine(hipsX - width * 0.1f, hipsY, hipsX + width * 0.1f, hipsY, outline);
|
||||
canvas.drawLine(hipsX, hipsY - width * 0.1f, hipsX, hipsY + width * 0.1f, outline);
|
||||
}
|
||||
if (pose.frozenShell()) drawFrozenShell(canvas, fill, outline, x, y, width, height, alpha);
|
||||
if (pose.conductiveArcs()) drawConductiveArcs(canvas, outline, path,
|
||||
x, y, width, height, phase, alpha);
|
||||
if (motion == VolunteerRig.Motion.RESPAWN) drawAssemblyRings(canvas, outline,
|
||||
x, y, width, height, pose.assemblyProgress(), alpha);
|
||||
}
|
||||
|
||||
private static void drawFrozenShell(Canvas canvas, Paint fill, Paint outline,
|
||||
float x, float y, float width, float height, float alpha) {
|
||||
fill.setColor(withAlpha(Color.rgb(85, 205, 236), alpha * 0.34f));
|
||||
canvas.drawRoundRect(x - width * 0.12f, y + height * 0.02f,
|
||||
x + width * 1.12f, y + height,
|
||||
width * 0.12f, width * 0.12f, fill);
|
||||
outline.setColor(withAlpha(Color.rgb(218, 252, 255), alpha));
|
||||
outline.setStrokeWidth(Math.max(2f, width * 0.045f));
|
||||
canvas.drawLine(x, y + height * 0.18f, x + width * 0.28f, y + height * 0.45f, outline);
|
||||
canvas.drawLine(x + width, y + height * 0.16f,
|
||||
x + width * 0.72f, y + height * 0.52f, outline);
|
||||
}
|
||||
|
||||
private static void drawConductiveArcs(Canvas canvas, Paint outline, Path path,
|
||||
float x, float y, float width, float height,
|
||||
float phase, float alpha) {
|
||||
outline.setColor(withAlpha(Color.rgb(255, 239, 74), alpha));
|
||||
outline.setStrokeWidth(Math.max(3f, width * 0.055f));
|
||||
float pulse = ((int) (phase * 12f) & 1) == 0 ? 0f : width * 0.06f;
|
||||
path.reset();
|
||||
path.moveTo(x - width * 0.15f, y + height * 0.3f);
|
||||
path.lineTo(x + width * 0.08f, y + height * 0.22f + pulse);
|
||||
path.lineTo(x - width * 0.02f, y + height * 0.52f);
|
||||
path.lineTo(x + width * 0.18f, y + height * 0.68f - pulse);
|
||||
canvas.drawPath(path, outline);
|
||||
path.reset();
|
||||
path.moveTo(x + width * 1.15f, y + height * 0.28f);
|
||||
path.lineTo(x + width * 0.91f, y + height * 0.43f - pulse);
|
||||
path.lineTo(x + width * 1.03f, y + height * 0.66f);
|
||||
path.lineTo(x + width * 0.82f, y + height * 0.77f + pulse);
|
||||
canvas.drawPath(path, outline);
|
||||
}
|
||||
|
||||
private static void drawAssemblyRings(Canvas canvas, Paint outline,
|
||||
float x, float y, float width, float height,
|
||||
float progress, float alpha) {
|
||||
outline.setColor(withAlpha(Color.rgb(116, 232, 213), alpha * (1f - progress * 0.3f)));
|
||||
outline.setStrokeWidth(Math.max(2f, width * 0.045f));
|
||||
float cx = x + width * 0.5f;
|
||||
float cy = y + height * 0.55f;
|
||||
canvas.drawCircle(cx, cy, width * (0.25f + progress * 0.45f), outline);
|
||||
canvas.drawCircle(cx, cy, width * (0.48f + progress * 0.35f), outline);
|
||||
}
|
||||
|
||||
private static void drawJoint(Canvas canvas, Paint fill, VolunteerRig.Pose pose,
|
||||
VolunteerRig.Joint joint, float x, float y,
|
||||
float width, float height, int color, float alpha) {
|
||||
fill.setColor(withAlpha(color, alpha));
|
||||
canvas.drawCircle(px(pose, joint, x, width), py(pose, joint, y, height),
|
||||
Math.max(2.5f, width * 0.065f), fill);
|
||||
}
|
||||
|
||||
private static void line(Canvas canvas, Paint outline, VolunteerRig.Pose pose,
|
||||
VolunteerRig.Joint from, VolunteerRig.Joint to,
|
||||
float x, float y, float width, float height) {
|
||||
canvas.drawLine(px(pose, from, x, width), py(pose, from, y, height),
|
||||
px(pose, to, x, width), py(pose, to, y, height), outline);
|
||||
}
|
||||
|
||||
private static float px(VolunteerRig.Pose pose, VolunteerRig.Joint joint,
|
||||
float x, float width) {
|
||||
return x + pose.x(joint) * width;
|
||||
}
|
||||
|
||||
private static float py(VolunteerRig.Pose pose, VolunteerRig.Joint joint,
|
||||
float y, float height) {
|
||||
return y + pose.y(joint) * height;
|
||||
}
|
||||
|
||||
private static VolunteerRig.Pose cachedPose(VolunteerRig.Skin skin,
|
||||
VolunteerRig.Motion motion,
|
||||
float phase, int facing) {
|
||||
int frame = Math.min(FRAME_COUNT - 1,
|
||||
Math.max(0, (int) ((phase - Math.floor(phase)) * FRAME_COUNT)));
|
||||
return POSES[skin.ordinal()][motion.ordinal()][facing < 0 ? 0 : 1][frame];
|
||||
}
|
||||
|
||||
private static VolunteerRig.Pose[][][][] buildPoseCache() {
|
||||
VolunteerRig.Skin[] skins = VolunteerRig.Skin.values();
|
||||
VolunteerRig.Motion[] motions = VolunteerRig.Motion.values();
|
||||
VolunteerRig.Pose[][][][] result =
|
||||
new VolunteerRig.Pose[skins.length][motions.length][2][FRAME_COUNT];
|
||||
for (VolunteerRig.Skin skin : skins) {
|
||||
for (VolunteerRig.Motion motion : motions) {
|
||||
for (int facingIndex = 0; facingIndex < 2; facingIndex++) {
|
||||
for (int frame = 0; frame < FRAME_COUNT; frame++) {
|
||||
result[skin.ordinal()][motion.ordinal()][facingIndex][frame] =
|
||||
VolunteerRig.pose(skin, motion, frame / (float) (FRAME_COUNT - 1),
|
||||
facingIndex == 0 ? -1 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int withAlpha(int color, float alpha) {
|
||||
int clamped = Math.max(0, Math.min(255, Math.round(Color.alpha(color) * alpha)));
|
||||
return Color.argb(clamped, Color.red(color), Color.green(color), Color.blue(color));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Skin-independent normalized skeleton for the volunteer renderer.
|
||||
* Gameplay collision remains external and is never derived from these points.
|
||||
*/
|
||||
public final class VolunteerRig {
|
||||
public static final float COLLISION_WIDTH = 62f;
|
||||
public static final float COLLISION_HEIGHT = 100f;
|
||||
|
||||
public enum Joint {
|
||||
ROOT,
|
||||
HIPS,
|
||||
CHEST,
|
||||
HEAD,
|
||||
LEFT_SHOULDER,
|
||||
RIGHT_SHOULDER,
|
||||
LEFT_HAND,
|
||||
RIGHT_HAND,
|
||||
LEFT_KNEE,
|
||||
RIGHT_KNEE,
|
||||
LEFT_FOOT,
|
||||
RIGHT_FOOT
|
||||
}
|
||||
|
||||
public enum Skin {
|
||||
BRASS_SCOUT(new Palette(0xffd5a64c, 0xff415064, 0xffb33f49, 0xffffd96b)),
|
||||
VERDIGRIS_KEEPER(new Palette(0xff69a99c, 0xff263f49, 0xffd09d54, 0xffc8fff1)),
|
||||
EMBER_COURIER(new Palette(0xffd87648, 0xff4b3440, 0xff6e95bb, 0xffffe09a));
|
||||
|
||||
private final Palette palette;
|
||||
|
||||
Skin(Palette palette) {
|
||||
this.palette = palette;
|
||||
}
|
||||
|
||||
public Palette palette() {
|
||||
return palette;
|
||||
}
|
||||
|
||||
public static Skin forLevel(int oneBasedLevelNumber) {
|
||||
int index = Math.max(1, oneBasedLevelNumber) - 1;
|
||||
return values()[index % values().length];
|
||||
}
|
||||
}
|
||||
|
||||
public enum Motion {
|
||||
IDLE,
|
||||
RUN,
|
||||
JUMP,
|
||||
FALL,
|
||||
DEAD,
|
||||
PINNED,
|
||||
FROZEN,
|
||||
CONDUCTIVE,
|
||||
RESPAWN
|
||||
}
|
||||
|
||||
public static final class Palette {
|
||||
private final int shell;
|
||||
private final int frame;
|
||||
private final int cloth;
|
||||
private final int glow;
|
||||
|
||||
private Palette(int shell, int frame, int cloth, int glow) {
|
||||
this.shell = shell;
|
||||
this.frame = frame;
|
||||
this.cloth = cloth;
|
||||
this.glow = glow;
|
||||
}
|
||||
|
||||
public int shell() { return shell; }
|
||||
public int frame() { return frame; }
|
||||
public int cloth() { return cloth; }
|
||||
public int glow() { return glow; }
|
||||
}
|
||||
|
||||
public static final class Pose {
|
||||
private final float[] x;
|
||||
private final float[] y;
|
||||
private final Palette palette;
|
||||
private final Motion motion;
|
||||
private final boolean visorLit;
|
||||
private final float assemblyProgress;
|
||||
|
||||
private Pose(float[] x, float[] y, Palette palette, Motion motion,
|
||||
boolean visorLit, float assemblyProgress) {
|
||||
this.x = Arrays.copyOf(x, x.length);
|
||||
this.y = Arrays.copyOf(y, y.length);
|
||||
this.palette = palette;
|
||||
this.motion = motion;
|
||||
this.visorLit = visorLit;
|
||||
this.assemblyProgress = assemblyProgress;
|
||||
}
|
||||
|
||||
public int jointCount() { return x.length; }
|
||||
public float x(Joint joint) { return x[joint.ordinal()]; }
|
||||
public float y(Joint joint) { return y[joint.ordinal()]; }
|
||||
public Palette palette() { return palette; }
|
||||
public Motion motion() { return motion; }
|
||||
public float collisionWidth() { return COLLISION_WIDTH; }
|
||||
public float collisionHeight() { return COLLISION_HEIGHT; }
|
||||
public boolean visorLit() { return visorLit; }
|
||||
public boolean frozenShell() { return motion == Motion.FROZEN; }
|
||||
public boolean conductiveArcs() { return motion == Motion.CONDUCTIVE; }
|
||||
public boolean anchorBolt() { return motion == Motion.PINNED; }
|
||||
public float assemblyProgress() { return assemblyProgress; }
|
||||
}
|
||||
|
||||
private VolunteerRig() {}
|
||||
|
||||
public static Motion aliveMotion(boolean onGround, float horizontalVelocity,
|
||||
float verticalVelocity) {
|
||||
if (!onGround) return verticalVelocity < 0f ? Motion.JUMP : Motion.FALL;
|
||||
return Math.abs(horizontalVelocity) >= 30f ? Motion.RUN : Motion.IDLE;
|
||||
}
|
||||
|
||||
public static Motion bodyMotion(boolean frozen, boolean conductive, boolean pinned) {
|
||||
if (frozen) return Motion.FROZEN;
|
||||
if (conductive) return Motion.CONDUCTIVE;
|
||||
if (pinned) return Motion.PINNED;
|
||||
return Motion.DEAD;
|
||||
}
|
||||
|
||||
public static Pose pose(Skin skin, Motion motion, float phase, int facing) {
|
||||
Skin safeSkin = skin == null ? Skin.BRASS_SCOUT : skin;
|
||||
Motion safeMotion = motion == null ? Motion.IDLE : motion;
|
||||
float cycle = phase - (float) Math.floor(phase);
|
||||
float swing = (float) Math.sin(cycle * Math.PI * 2f);
|
||||
float bounce = (float) Math.sin(cycle * Math.PI * 2f) * 0.018f;
|
||||
|
||||
float[] xs = {
|
||||
0.50f, 0.50f, 0.50f, 0.50f,
|
||||
0.34f, 0.66f, 0.25f, 0.75f,
|
||||
0.40f, 0.60f, 0.31f, 0.69f
|
||||
};
|
||||
float[] ys = {
|
||||
0.96f, 0.67f, 0.43f, 0.20f,
|
||||
0.43f, 0.43f, 0.66f, 0.66f,
|
||||
0.79f, 0.79f, 0.96f, 0.96f
|
||||
};
|
||||
|
||||
switch (safeMotion) {
|
||||
case IDLE:
|
||||
ys[Joint.CHEST.ordinal()] += bounce;
|
||||
ys[Joint.HEAD.ordinal()] += bounce;
|
||||
break;
|
||||
case RUN:
|
||||
xs[Joint.LEFT_HAND.ordinal()] += swing * 0.18f;
|
||||
xs[Joint.RIGHT_HAND.ordinal()] -= swing * 0.18f;
|
||||
xs[Joint.LEFT_KNEE.ordinal()] -= swing * 0.13f;
|
||||
xs[Joint.RIGHT_KNEE.ordinal()] += swing * 0.13f;
|
||||
xs[Joint.LEFT_FOOT.ordinal()] -= swing * 0.18f;
|
||||
xs[Joint.RIGHT_FOOT.ordinal()] += swing * 0.18f;
|
||||
ys[Joint.ROOT.ordinal()] -= Math.abs(swing) * 0.025f;
|
||||
break;
|
||||
case JUMP:
|
||||
xs[Joint.LEFT_HAND.ordinal()] = 0.16f;
|
||||
xs[Joint.RIGHT_HAND.ordinal()] = 0.84f;
|
||||
ys[Joint.LEFT_HAND.ordinal()] = 0.35f;
|
||||
ys[Joint.RIGHT_HAND.ordinal()] = 0.35f;
|
||||
xs[Joint.LEFT_FOOT.ordinal()] = 0.42f;
|
||||
xs[Joint.RIGHT_FOOT.ordinal()] = 0.58f;
|
||||
ys[Joint.LEFT_FOOT.ordinal()] = 0.88f;
|
||||
ys[Joint.RIGHT_FOOT.ordinal()] = 0.88f;
|
||||
break;
|
||||
case FALL:
|
||||
xs[Joint.LEFT_HAND.ordinal()] = 0.18f;
|
||||
xs[Joint.RIGHT_HAND.ordinal()] = 0.82f;
|
||||
ys[Joint.LEFT_HAND.ordinal()] = 0.51f;
|
||||
ys[Joint.RIGHT_HAND.ordinal()] = 0.51f;
|
||||
xs[Joint.LEFT_FOOT.ordinal()] = 0.23f;
|
||||
xs[Joint.RIGHT_FOOT.ordinal()] = 0.77f;
|
||||
break;
|
||||
case DEAD:
|
||||
case PINNED:
|
||||
case FROZEN:
|
||||
case CONDUCTIVE:
|
||||
xs[Joint.LEFT_HAND.ordinal()] = 0.15f;
|
||||
xs[Joint.RIGHT_HAND.ordinal()] = 0.85f;
|
||||
ys[Joint.LEFT_HAND.ordinal()] = 0.72f;
|
||||
ys[Joint.RIGHT_HAND.ordinal()] = 0.72f;
|
||||
xs[Joint.LEFT_FOOT.ordinal()] = 0.24f;
|
||||
xs[Joint.RIGHT_FOOT.ordinal()] = 0.76f;
|
||||
break;
|
||||
case RESPAWN:
|
||||
float assembly = clamp01(phase);
|
||||
for (int i = 0; i < ys.length; i++) {
|
||||
ys[i] = 1f - (1f - ys[i]) * assembly;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (facing < 0) {
|
||||
for (int i = 0; i < xs.length; i++) xs[i] = 1f - xs[i];
|
||||
}
|
||||
boolean visorLit = safeMotion != Motion.DEAD
|
||||
&& safeMotion != Motion.PINNED
|
||||
&& safeMotion != Motion.FROZEN
|
||||
&& safeMotion != Motion.CONDUCTIVE;
|
||||
float assemblyProgress = safeMotion == Motion.RESPAWN ? clamp01(phase) : 1f;
|
||||
return new Pose(xs, ys, safeSkin.palette(), safeMotion, visorLit, assemblyProgress);
|
||||
}
|
||||
|
||||
private static float clamp01(float value) {
|
||||
return Math.max(0f, Math.min(1f, value));
|
||||
}
|
||||
}
|
||||
@@ -50,4 +50,17 @@ public final class AccessibilitySettingsTest {
|
||||
assertEquals(24f, AccessibilitySettings.defaults().canvasTextSize(24f), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reducedMotionIsExplicitReversibleAndRestorable() {
|
||||
AccessibilitySettings settings = AccessibilitySettings.defaults();
|
||||
|
||||
assertFalse(settings.reducedMotionEnabled());
|
||||
assertEquals("Off", settings.reducedMotionLabel());
|
||||
settings = settings.toggleReducedMotion();
|
||||
assertTrue(settings.reducedMotionEnabled());
|
||||
assertEquals("On", settings.reducedMotionLabel());
|
||||
assertFalse(settings.toggleReducedMotion().reducedMotionEnabled());
|
||||
assertTrue(AccessibilitySettings.restore(0, false, true).reducedMotionEnabled());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class AudioCueSequencerTest {
|
||||
@Test
|
||||
public void multiVariantDeathVoicesNeverRepeatImmediately() {
|
||||
AudioCueSequencer sequencer = new AudioCueSequencer(17L);
|
||||
int previous = sequencer.nextVariant(AudioCue.DEATH_VOICE);
|
||||
|
||||
for (int i = 0; i < 30; i++) {
|
||||
int next = sequencer.nextVariant(AudioCue.DEATH_VOICE);
|
||||
assertNotEquals(previous, next);
|
||||
previous = next;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void singleVariantCuesRemainStableAndChannelsAreExplicit() {
|
||||
AudioCueSequencer sequencer = new AudioCueSequencer(9L);
|
||||
|
||||
assertEquals(0, sequencer.nextVariant(AudioCue.UI_TAP));
|
||||
assertEquals(0, sequencer.nextVariant(AudioCue.UI_TAP));
|
||||
assertEquals(AudioCue.Channel.EFFECTS, AudioCue.JUMP.channel());
|
||||
assertEquals(AudioCue.Channel.VOICE, AudioCue.DEATH_VOICE.channel());
|
||||
assertEquals(AudioCue.Channel.MUSIC, AudioCue.MUSIC_PULSE.channel());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class AudioSettingsTest {
|
||||
@Test
|
||||
public void musicEffectsAndVoiceVolumesCycleIndependently() {
|
||||
AudioSettings settings = AudioSettings.defaults();
|
||||
|
||||
assertEquals(0.66f, settings.musicVolume(), 0f);
|
||||
assertEquals(1f, settings.effectsVolume(), 0f);
|
||||
assertEquals(1f, settings.voiceVolume(), 0f);
|
||||
|
||||
AudioSettings quieterMusic = settings.nextMusicVolume();
|
||||
assertEquals(1f, quieterMusic.musicVolume(), 0f);
|
||||
assertEquals(settings.effectsVolume(), quieterMusic.effectsVolume(), 0f);
|
||||
assertEquals(settings.voiceVolume(), quieterMusic.voiceVolume(), 0f);
|
||||
|
||||
AudioSettings mutedEffects = settings.nextEffectsVolume();
|
||||
assertEquals(0f, mutedEffects.effectsVolume(), 0f);
|
||||
assertEquals(settings.musicVolume(), mutedEffects.musicVolume(), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidPersistedPresetsRestoreToSafeDefaults() {
|
||||
AudioSettings restored = AudioSettings.restore(-4, 99, 2);
|
||||
|
||||
assertEquals(AudioSettings.defaults().musicPreset(), restored.musicPreset());
|
||||
assertEquals(AudioSettings.defaults().effectsPreset(), restored.effectsPreset());
|
||||
assertEquals(2, restored.voicePreset());
|
||||
assertEquals("66%", restored.volumeLabel(restored.voicePreset()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class CameraControllerTest {
|
||||
@Test
|
||||
public void followUsesHorizontalAndVerticalDampingWithinWorldBounds() {
|
||||
CameraController camera = new CameraController(1_920f, 1_080f);
|
||||
camera.reset();
|
||||
|
||||
CameraController.Frame frame = camera.update(
|
||||
0.25f, 1_600f, 1_250f, 1, 3_200f, 1_800f, false);
|
||||
|
||||
assertTrue(frame.x() > 0f && frame.x() <= 1_280f);
|
||||
assertTrue(frame.y() > 0f && frame.y() <= 720f);
|
||||
CameraController.Frame clamped = camera.update(
|
||||
5f, 9_000f, 9_000f, 1, 3_200f, 1_800f, false);
|
||||
assertTrue(clamped.x() <= 1_280f);
|
||||
assertTrue(clamped.y() <= 720f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void temporaryFocusIsBoundedAndExpiresBackToPlayerFollow() {
|
||||
CameraController camera = new CameraController(1_920f, 1_080f);
|
||||
camera.reset();
|
||||
camera.requestFocus(2_500f, 400f, 0.35f);
|
||||
|
||||
CameraController.Frame focused = camera.update(
|
||||
0.1f, 700f, 780f, 1, 3_200f, 1_080f, false);
|
||||
assertTrue(focused.focusActive());
|
||||
assertTrue(focused.focusOffsetX() <= CameraController.MAX_FOCUS_OFFSET);
|
||||
|
||||
CameraController.Frame expired = camera.update(
|
||||
0.4f, 700f, 780f, 1, 3_200f, 1_080f, false);
|
||||
assertFalse(expired.focusActive());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void impactShakeIsBoundedAndReducedMotionDisablesIt() {
|
||||
CameraController camera = new CameraController(1_920f, 1_080f);
|
||||
camera.reset();
|
||||
camera.impact(24f);
|
||||
|
||||
CameraController.Frame shaking = camera.update(
|
||||
0.016f, 700f, 780f, 1, 3_200f, 1_080f, false);
|
||||
assertTrue(Math.abs(shaking.shakeX()) <= CameraController.MAX_SHAKE);
|
||||
assertTrue(Math.abs(shaking.shakeY()) <= CameraController.MAX_SHAKE);
|
||||
|
||||
CameraController.Frame reduced = camera.update(
|
||||
0.016f, 700f, 780f, 1, 3_200f, 1_080f, true);
|
||||
assertTrue(reduced.shakeX() == 0f);
|
||||
assertTrue(reduced.shakeY() == 0f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class DeterministicInputTapeTest {
|
||||
@Test
|
||||
public void oneRecordedCommandTapeReplaysToTheSameTerminalSignature() {
|
||||
DeterministicInputTape tape = DeterministicInputTape.builder(2)
|
||||
.record(false, true, false, 16L)
|
||||
.record(false, true, true, 32L)
|
||||
.record(false, false, false, 16L)
|
||||
.build();
|
||||
|
||||
FakeTarget first = new FakeTarget();
|
||||
FakeTarget second = new FakeTarget();
|
||||
|
||||
assertEquals(tape.play(first), tape.play(second));
|
||||
assertEquals("level=2;ticks=64;mask=0", first.terminalSignature());
|
||||
assertEquals(3, tape.commands().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tapeIdIsStableButChangesWithCommands() {
|
||||
DeterministicInputTape first = DeterministicInputTape.builder(1)
|
||||
.record(false, true, false, 16L).build();
|
||||
DeterministicInputTape same = DeterministicInputTape.builder(1)
|
||||
.record(false, true, false, 16L).build();
|
||||
DeterministicInputTape changed = DeterministicInputTape.builder(1)
|
||||
.record(false, true, true, 16L).build();
|
||||
|
||||
assertEquals(first.id(), same.id());
|
||||
assertNotEquals(first.id(), changed.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void terminalStateSignatureIncludesPuzzleBytesAndCompletionFlags() {
|
||||
byte[] puzzle = {4, 8, 15, 16, 23, 42};
|
||||
|
||||
assertEquals(ReplayStateSignature.of(puzzle, true, false),
|
||||
ReplayStateSignature.of(puzzle, true, false));
|
||||
assertNotEquals(ReplayStateSignature.of(puzzle, true, false),
|
||||
ReplayStateSignature.of(puzzle, false, false));
|
||||
}
|
||||
|
||||
private static final class FakeTarget implements DeterministicInputTape.Target {
|
||||
private int level;
|
||||
private long ticks;
|
||||
private int mask;
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) { level = oneBasedLevelNumber; }
|
||||
|
||||
@Override
|
||||
public void setInput(boolean left, boolean right, boolean jump) {
|
||||
mask = (left ? 1 : 0) | (right ? 2 : 0) | (jump ? 4 : 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void advance(long milliseconds) { ticks += milliseconds; }
|
||||
|
||||
@Override
|
||||
public String terminalSignature() {
|
||||
return "level=" + level + ";ticks=" + ticks + ";mask=" + mask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class PerformanceMonitorTest {
|
||||
@Test
|
||||
public void rollingP95AndComplexityAreComparedWithPublishedBudgets() {
|
||||
PerformanceMonitor monitor = new PerformanceMonitor(240);
|
||||
for (int frame = 0; frame < 240; frame++) {
|
||||
monitor.recordFrame(16f, 3f, 7f);
|
||||
monitor.recordComplexity(6, 180, 900, 48L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
PerformanceMonitor.Snapshot healthy = monitor.snapshot();
|
||||
assertEquals(240, healthy.sampleCount());
|
||||
assertEquals(16f, healthy.p95FrameIntervalMs(), 0f);
|
||||
assertTrue(healthy.within(PerformanceBudget.release()));
|
||||
assertEquals(6, healthy.maximumBodies());
|
||||
|
||||
for (int frame = 0; frame < 24; frame++) {
|
||||
monitor.recordFrame(75f, 18f, 30f);
|
||||
monitor.recordComplexity(17, 4_000, 3_000, 140L * 1024L * 1024L);
|
||||
}
|
||||
PerformanceMonitor.Snapshot overloaded = monitor.snapshot();
|
||||
assertFalse(overloaded.within(PerformanceBudget.release()));
|
||||
assertTrue(overloaded.failureSummary(PerformanceBudget.release()).contains("frameP95"));
|
||||
assertTrue(overloaded.failureSummary(PerformanceBudget.release()).contains("bodies"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class PhysicsConfigTest {
|
||||
@Test
|
||||
public void releasePhysicsIsOneVersionedFiniteSourceOfTruth() {
|
||||
PhysicsConfig physics = PhysicsConfig.release();
|
||||
|
||||
assertEquals(1, physics.version());
|
||||
assertEquals(1f / 60f, physics.fixedStepSeconds(), 0f);
|
||||
assertEquals(1_800f, physics.gravity(), 0f);
|
||||
assertEquals(370f, physics.runSpeed(), 0f);
|
||||
assertEquals(880f, physics.jumpSpeed(), 0f);
|
||||
assertEquals(0.13f, physics.coyoteSeconds(), 0f);
|
||||
assertEquals(0.14f, physics.jumpBufferSeconds(), 0f);
|
||||
assertEquals(0.78f, physics.respawnSeconds(), 0f);
|
||||
assertTrue(physics.maximumBodies() >= 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class VersionedSessionCodecTest {
|
||||
@Test
|
||||
public void currentSnapshotRoundTripsEveryPuzzleCriticalField() {
|
||||
GameSessionSnapshot original = sampleSnapshot();
|
||||
|
||||
VersionedSessionCodec.DecodeResult decoded =
|
||||
VersionedSessionCodec.decode(VersionedSessionCodec.encode(original));
|
||||
|
||||
assertEquals(VersionedSessionCodec.Status.RESTORED, decoded.status());
|
||||
GameSessionSnapshot restored = decoded.snapshot();
|
||||
assertEquals(5, restored.levelNumber());
|
||||
assertEquals(12.75f, restored.elapsedSeconds(), 0f);
|
||||
assertEquals(2, restored.deaths());
|
||||
assertEquals(640f, restored.player().x(), 0f);
|
||||
assertFalse(restored.player().alive());
|
||||
assertEquals(2, restored.bodies().size());
|
||||
assertTrue(restored.bodies().get(0).pinned());
|
||||
assertTrue(restored.bodies().get(1).conductive());
|
||||
assertEquals(Arrays.asList(1.25f, 3.5f), restored.platformMotionTimes());
|
||||
assertTrue(restored.toggleSignals().get("gate"));
|
||||
assertEquals(2.25f, restored.timedSignals().get("clock"), 0f);
|
||||
assertTrue(restored.previousSwitchContacts().get("gate@420.0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyV1MigratesWithSafeEmptyEdgeContacts() {
|
||||
VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode(
|
||||
VersionedSessionCodec.encodeLegacyV1(sampleSnapshot()));
|
||||
|
||||
assertEquals(VersionedSessionCodec.Status.MIGRATED, decoded.status());
|
||||
assertEquals(VersionedSessionCodec.CURRENT_VERSION,
|
||||
decoded.snapshot().saveVersion());
|
||||
assertTrue(decoded.snapshot().previousSwitchContacts().isEmpty());
|
||||
assertEquals(2, decoded.snapshot().bodies().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void corruptAndFutureSnapshotsFailClosed() {
|
||||
VersionedSessionCodec.DecodeResult corrupt =
|
||||
VersionedSessionCodec.decode(new byte[] {1, 2, 3});
|
||||
VersionedSessionCodec.DecodeResult future =
|
||||
VersionedSessionCodec.decode(VersionedSessionCodec.encodeVersionForTest(99));
|
||||
|
||||
assertEquals(VersionedSessionCodec.Status.CORRUPT, corrupt.status());
|
||||
assertNull(corrupt.snapshot());
|
||||
assertEquals(VersionedSessionCodec.Status.UNSUPPORTED, future.status());
|
||||
assertNull(future.snapshot());
|
||||
}
|
||||
|
||||
private static GameSessionSnapshot sampleSnapshot() {
|
||||
return GameSessionSnapshot.builder(5)
|
||||
.elapsedSeconds(12.75f)
|
||||
.deaths(2)
|
||||
.respawnSeconds(0.4f)
|
||||
.player(new GameSessionSnapshot.PlayerData(
|
||||
640f, 780f, 120f, -40f, false, false,
|
||||
0.1f, 0.08f, -1, -1))
|
||||
.addBody(new GameSessionSnapshot.BodyData(
|
||||
500f, 826f, 72f, 54f, 0f, 0f,
|
||||
false, false, true, true, -1, "SPIKE"))
|
||||
.addBody(new GameSessionSnapshot.BodyData(
|
||||
720f, 826f, 72f, 54f, 0f, 0f,
|
||||
false, true, true, true, -1, "ELECTRIC"))
|
||||
.platformMotionTimes(Arrays.asList(1.25f, 3.5f))
|
||||
.toggleSignal("gate", true)
|
||||
.timedSignal("clock", 2.25f)
|
||||
.previousSwitchContact("gate@420.0", true)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class VolunteerRigTest {
|
||||
@Test
|
||||
public void skinsReuseTheSameNamedSkeletonAndOnlyChangePalette() {
|
||||
VolunteerRig.Pose brass = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.IDLE, 0f, 1);
|
||||
VolunteerRig.Pose verdigris = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.VERDIGRIS_KEEPER, VolunteerRig.Motion.IDLE, 0f, 1);
|
||||
|
||||
assertEquals(VolunteerRig.Joint.values().length, brass.jointCount());
|
||||
for (VolunteerRig.Joint joint : VolunteerRig.Joint.values()) {
|
||||
assertEquals(brass.x(joint), verdigris.x(joint), 0f);
|
||||
assertEquals(brass.y(joint), verdigris.y(joint), 0f);
|
||||
assertTrue(brass.x(joint) >= 0f && brass.x(joint) <= 1f);
|
||||
assertTrue(brass.y(joint) >= 0f && brass.y(joint) <= 1f);
|
||||
}
|
||||
assertNotEquals(brass.palette().shell(), verdigris.palette().shell());
|
||||
assertNotEquals(brass.palette().cloth(), verdigris.palette().cloth());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void motionChangesLimbsWithoutChangingCollisionOrRootContract() {
|
||||
VolunteerRig.Pose idle = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.EMBER_COURIER, VolunteerRig.Motion.IDLE, 0f, 1);
|
||||
VolunteerRig.Pose run = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.EMBER_COURIER, VolunteerRig.Motion.RUN, 0.25f, 1);
|
||||
VolunteerRig.Pose mirrored = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.EMBER_COURIER, VolunteerRig.Motion.RUN, 0.25f, -1);
|
||||
|
||||
assertEquals(62f, idle.collisionWidth(), 0f);
|
||||
assertEquals(100f, idle.collisionHeight(), 0f);
|
||||
assertEquals(idle.x(VolunteerRig.Joint.ROOT), run.x(VolunteerRig.Joint.ROOT), 0f);
|
||||
assertNotEquals(idle.x(VolunteerRig.Joint.LEFT_HAND),
|
||||
run.x(VolunteerRig.Joint.LEFT_HAND), 0.001f);
|
||||
assertEquals(1f - run.x(VolunteerRig.Joint.LEFT_HAND),
|
||||
mirrored.x(VolunteerRig.Joint.LEFT_HAND), 0.001f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void gameplayStatesHaveNonColorVisualSignals() {
|
||||
VolunteerRig.Pose dead = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.DEAD, 0f, 1);
|
||||
VolunteerRig.Pose frozen = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.FROZEN, 0f, 1);
|
||||
VolunteerRig.Pose conductive = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.CONDUCTIVE, 0f, 1);
|
||||
VolunteerRig.Pose pinned = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.PINNED, 0f, 1);
|
||||
VolunteerRig.Pose respawn = VolunteerRig.pose(
|
||||
VolunteerRig.Skin.BRASS_SCOUT, VolunteerRig.Motion.RESPAWN, 0.5f, 1);
|
||||
|
||||
assertFalse(dead.visorLit());
|
||||
assertTrue(frozen.frozenShell());
|
||||
assertTrue(conductive.conductiveArcs());
|
||||
assertTrue(pinned.anchorBolt());
|
||||
assertEquals(0.5f, respawn.assemblyProgress(), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void gameplayMotionIsDerivedFromGroundVelocityAndBodyState() {
|
||||
assertEquals(VolunteerRig.Motion.IDLE,
|
||||
VolunteerRig.aliveMotion(true, 0f, 0f));
|
||||
assertEquals(VolunteerRig.Motion.RUN,
|
||||
VolunteerRig.aliveMotion(true, 370f, 0f));
|
||||
assertEquals(VolunteerRig.Motion.JUMP,
|
||||
VolunteerRig.aliveMotion(false, 120f, -400f));
|
||||
assertEquals(VolunteerRig.Motion.FALL,
|
||||
VolunteerRig.aliveMotion(false, 120f, 200f));
|
||||
|
||||
assertEquals(VolunteerRig.Motion.FROZEN,
|
||||
VolunteerRig.bodyMotion(true, true, true));
|
||||
assertEquals(VolunteerRig.Motion.CONDUCTIVE,
|
||||
VolunteerRig.bodyMotion(false, true, true));
|
||||
assertEquals(VolunteerRig.Motion.PINNED,
|
||||
VolunteerRig.bodyMotion(false, false, true));
|
||||
assertEquals(VolunteerRig.Motion.DEAD,
|
||||
VolunteerRig.bodyMotion(false, false, false));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user