Добавить production-каталог опасностей
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.os.SystemClock;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class LevelPaginationTest {
|
||||
@Test
|
||||
public void nextAndPreviousControlsExposeEveryAuthoredLevel() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.setLanguageForAutomation(GameLanguage.EN);
|
||||
assertEquals(0, view.levelPageIndexForAutomation());
|
||||
assertEquals(2, view.levelPageCountForAutomation());
|
||||
assertTrue(view.visibleLocalizedTextForAutomation().contains("The Volunteer"));
|
||||
assertFalse(view.visibleLocalizedTextForAutomation().contains("Steam Charge"));
|
||||
});
|
||||
|
||||
tap(activity, 1_730f, 965f);
|
||||
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
List<String> visible = view.visibleLocalizedTextForAutomation();
|
||||
assertEquals(1, view.levelPageIndexForAutomation());
|
||||
assertTrue(visible.contains("Steam Charge"));
|
||||
assertFalse(visible.contains("The Volunteer"));
|
||||
assertTrue(visible.contains("Page 2 of 2"));
|
||||
});
|
||||
|
||||
tap(activity, 190f, 965f);
|
||||
activity.onActivity(current ->
|
||||
assertEquals(0, current.gameViewForAutomation().levelPageIndexForAutomation()));
|
||||
}
|
||||
}
|
||||
|
||||
private static void tap(ActivityScenario<MainActivity> activity, float canvasX, float canvasY) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
float scale = Math.min(view.getWidth() / 1_920f, view.getHeight() / 1_080f);
|
||||
float x = (view.getWidth() - 1_920f * scale) * 0.5f + canvasX * scale;
|
||||
float y = (view.getHeight() - 1_080f * scale) * 0.5f + canvasY * scale;
|
||||
long now = SystemClock.uptimeMillis();
|
||||
MotionEvent down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0);
|
||||
view.dispatchTouchEvent(down);
|
||||
down.recycle();
|
||||
MotionEvent up = MotionEvent.obtain(now, now, MotionEvent.ACTION_UP, x, y, 0);
|
||||
view.dispatchTouchEvent(up);
|
||||
up.recycle();
|
||||
});
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
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 ProductionHazardPlaythroughTest {
|
||||
private static final int CONSECUTIVE_RUNS = 3;
|
||||
|
||||
@Test public void crusherLevelCompletesThreeTimes() {
|
||||
prove(ProductionHazardScenarios.levelEleven(), 35_000L, false);
|
||||
}
|
||||
|
||||
@Test public void fireLevelCompletesThreeTimes() {
|
||||
prove(ProductionHazardScenarios.levelTwelve(), 35_000L, false);
|
||||
}
|
||||
|
||||
@Test public void projectileLevelCompletesThreeTimes() {
|
||||
prove(ProductionHazardScenarios.levelThirteen(), 40_000L, false);
|
||||
}
|
||||
|
||||
@Test public void explosionDeliversBodyAndOpensGateThreeTimes() {
|
||||
prove(ProductionHazardScenarios.levelFourteen(), 45_000L, true);
|
||||
}
|
||||
|
||||
private static void prove(LevelPlaytestBot.Scenario scenario, long budget,
|
||||
boolean requiresDoor) {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
AndroidDriver driver = AndroidDriver.from(activity);
|
||||
for (int run = 1; run <= CONSECUTIVE_RUNS; run++) {
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, budget);
|
||||
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
|
||||
assertEquals("run=" + run + "; teaching route uses exactly one volunteer",
|
||||
1, result.deaths());
|
||||
assertEquals("run=" + run + "; sacrificed body remains in the level",
|
||||
1, result.corpseCount());
|
||||
if (requiresDoor) {
|
||||
assertTrue("run=" + run + "; blast-delivered body must open the gate",
|
||||
result.doorOpened());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AndroidDriver implements LevelPlaytestBot.Driver {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView gameView;
|
||||
|
||||
private AndroidDriver(Instrumentation instrumentation, GameView gameView) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.gameView = gameView;
|
||||
}
|
||||
|
||||
static AndroidDriver from(ActivityScenario<MainActivity> activity) {
|
||||
GameView[] view = new GameView[1];
|
||||
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
|
||||
return new AndroidDriver(InstrumentationRegistry.getInstrumentation(), view[0]);
|
||||
}
|
||||
|
||||
@Override public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(
|
||||
() -> gameView.startLevelForAutomation(oneBasedLevelNumber));
|
||||
}
|
||||
|
||||
@Override public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
|
||||
instrumentation.runOnMainSync(
|
||||
() -> gameView.setAutomationInput(moveLeft, moveRight, jump));
|
||||
}
|
||||
|
||||
@Override public GameView.AutomationSnapshot snapshot() {
|
||||
GameView.AutomationSnapshot[] snapshot = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> snapshot[0] = gameView.automationSnapshot());
|
||||
return snapshot[0];
|
||||
}
|
||||
|
||||
@Override public void waitForNextDecision(long millis) {
|
||||
try {
|
||||
Thread.sleep(Math.max(32L, millis));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError("Production-hazard bot interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Intended routes for the first four data-driven production hazards. */
|
||||
final class ProductionHazardScenarios {
|
||||
private ProductionHazardScenarios() {}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelEleven() {
|
||||
return LevelPlaytestBot.Scenario.builder(11, "Level 11 - crusher rhythm")
|
||||
.step("Sacrifice one volunteer to the press", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
|
||||
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the retained body", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
|
||||
.step("Observe the press close for a fresh cycle", 4_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
GameView.AutomationSnapshot::firstHazardActive)
|
||||
.step("Observe the press resting", 4_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> !snapshot.firstHazardActive())
|
||||
.step("Jump through the resting press", 2_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 900f))
|
||||
.step("Reach the relic", 6_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelTwelve() {
|
||||
return LevelPlaytestBot.Scenario.builder(12, "Level 12 - blue fire")
|
||||
.step("Sacrifice one volunteer to the flame", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
|
||||
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the blue flame", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(535f)))
|
||||
.step("Take off before the flame", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 700f))
|
||||
.step("Clear the flame", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 850f))
|
||||
.step("Reach the relic", 6_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelThirteen() {
|
||||
return LevelPlaytestBot.Scenario.builder(13, "Level 13 - clockwork projectile")
|
||||
.step("Read the bolt by sacrificing once", 5_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
|
||||
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the firing line", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(600f)))
|
||||
.step("Wait for the jump window", 4_000L, LevelPlaytestBot.Input.none(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.firstHazardX() >= 850f
|
||||
&& snapshot.firstHazardX() <= 930f))
|
||||
.step("Jump through the bolt line", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerY() <= 690f))
|
||||
.step("Clear the firing line", 2_500L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_050f))
|
||||
.step("Wait for the returning bolt", 4_000L, LevelPlaytestBot.Input.none(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.firstHazardX() >= 1_350f
|
||||
&& snapshot.firstHazardX() <= 1_450f))
|
||||
.step("Jump through the returning bolt", 1_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerY() <= 690f))
|
||||
.step("Clear the second firing line", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_600f))
|
||||
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelFourteen() {
|
||||
return LevelPlaytestBot.Scenario.builder(14, "Level 14 - explosion delivery")
|
||||
.step("Give one volunteer to the blast", 4_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1)
|
||||
.step("Observe the delivered body open the gate", 5_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
GameView.AutomationSnapshot::allDoorsOpen)
|
||||
.step("Wait for respawn", 3_000L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the steam charge", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(390f)))
|
||||
.step("Observe the charge rebuild", 4_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
GameView.AutomationSnapshot::firstHazardActive)
|
||||
.step("Observe the safe blast rest", 4_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> !snapshot.firstHazardActive())
|
||||
.step("Take off before the blast", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 690f))
|
||||
.step("Clear the blast", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 820f))
|
||||
.step("Approach the delivered body", 1_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 880f)
|
||||
.step("Jump over the delivered body", 2_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_200f))
|
||||
.step("Pass the opened gate", 4_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 1_550f)
|
||||
.step("Reach the relic", 5_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ public enum AudioCue {
|
||||
SWITCH(Channel.EFFECTS, 3),
|
||||
DOOR(Channel.EFFECTS, 2),
|
||||
DEATH_IMPACT(Channel.EFFECTS, 5),
|
||||
CRUSHER_IMPACT(Channel.EFFECTS, 3),
|
||||
FIRE_BURST(Channel.EFFECTS, 3),
|
||||
PROJECTILE_HIT(Channel.EFFECTS, 3),
|
||||
EXPLOSION_BLAST(Channel.EFFECTS, 3),
|
||||
DEATH_VOICE(Channel.VOICE, 4),
|
||||
RESPAWN_VOICE(Channel.VOICE, 3),
|
||||
GOAL_VOICE(Channel.VOICE, 3),
|
||||
|
||||
@@ -40,7 +40,7 @@ public final class DeterministicInputTape {
|
||||
private final ArrayList<Command> commands = new ArrayList<>();
|
||||
|
||||
private Builder(int levelNumber) {
|
||||
if (levelNumber < 1 || levelNumber > 10) {
|
||||
if (levelNumber < 1 || levelNumber > LevelCatalog.all().size()) {
|
||||
throw new IllegalArgumentException("Unknown level: " + levelNumber);
|
||||
}
|
||||
this.levelNumber = levelNumber;
|
||||
|
||||
@@ -22,8 +22,10 @@ public final class GameStrings {
|
||||
|
||||
public String appContentDescription() {
|
||||
return russian()
|
||||
? "Project Sacrifice. Платформенная головоломка из десяти уровней."
|
||||
: "Project Sacrifice. Ten-level puzzle platform game.";
|
||||
? "Project Sacrifice. Платформенная головоломка, уровней: "
|
||||
+ LevelCatalog.all().size() + "."
|
||||
: "Project Sacrifice. Puzzle platform game with "
|
||||
+ LevelCatalog.all().size() + " levels.";
|
||||
}
|
||||
|
||||
public String projectTitle() { return "PROJECT SACRIFICE"; }
|
||||
@@ -43,6 +45,14 @@ public final class GameStrings {
|
||||
return russian() ? "Настройки" : "Settings";
|
||||
}
|
||||
|
||||
public String previousPage() { return russian() ? "Назад" : "Previous"; }
|
||||
public String nextPage() { return russian() ? "Далее" : "Next"; }
|
||||
public String pageIndicator(int currentPage, int totalPages) {
|
||||
return russian()
|
||||
? "Страница " + currentPage + " из " + totalPages
|
||||
: "Page " + currentPage + " of " + totalPages;
|
||||
}
|
||||
|
||||
public String settingsTitle() { return russian() ? "НАСТРОЙКИ" : "SETTINGS"; }
|
||||
public String settingsInstruction() {
|
||||
return russian() ? "Нажмите на строку, чтобы изменить. Всё сохраняется автоматически."
|
||||
|
||||
@@ -43,9 +43,12 @@ public final class GameView extends View implements Runnable {
|
||||
private static final float JUMP_BUFFER_SECONDS = PHYSICS.jumpBufferSeconds();
|
||||
private static final float RESPAWN_SECONDS = PHYSICS.respawnSeconds();
|
||||
private static final int MAX_CORPSES = PHYSICS.maximumBodies();
|
||||
private static final int LEVELS_PER_PAGE = 10;
|
||||
|
||||
private enum Screen { LEVELS, SETTINGS, PLAYING, COMPLETE }
|
||||
private enum DeathType { SPIKE, SAW, FREEZE, ELECTRIC, PIT }
|
||||
private enum DeathType {
|
||||
SPIKE, SAW, FREEZE, ELECTRIC, PIT, CRUSH, FIRE, PROJECTILE, EXPLOSION
|
||||
}
|
||||
|
||||
public static final class AutomationSnapshot {
|
||||
private final boolean completed;
|
||||
@@ -69,6 +72,9 @@ public final class GameView extends View implements Runnable {
|
||||
private final boolean playerOnGround;
|
||||
private final float playerVelocityX;
|
||||
private final float playerVelocityY;
|
||||
private final boolean firstHazardActive;
|
||||
private final float firstHazardX;
|
||||
private final float firstHazardY;
|
||||
|
||||
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
|
||||
boolean visiblyPaused, boolean inputActive, int levelNumber,
|
||||
@@ -137,6 +143,26 @@ public final class GameView extends View implements Runnable {
|
||||
boolean firstMovingPlatformMovingDown, float firstCorpseX,
|
||||
boolean playerOnGround, float playerVelocityX,
|
||||
float playerVelocityY) {
|
||||
this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber,
|
||||
elapsedMilliseconds, playerX, playerY, deaths, corpseCount,
|
||||
firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform,
|
||||
firstMovingPlatformPowered, firstMovingPlatformMovingRight,
|
||||
firstMovingPlatformMovingDown, firstCorpseX, playerOnGround,
|
||||
playerVelocityX, playerVelocityY, false, Float.NaN, Float.NaN);
|
||||
}
|
||||
|
||||
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
|
||||
boolean visiblyPaused, boolean inputActive, int levelNumber,
|
||||
long elapsedMilliseconds,
|
||||
float playerX, float playerY, int deaths, int corpseCount,
|
||||
float firstMovingPlatformX, float firstMovingPlatformY,
|
||||
boolean playerOnMovingPlatform,
|
||||
boolean firstMovingPlatformPowered,
|
||||
boolean firstMovingPlatformMovingRight,
|
||||
boolean firstMovingPlatformMovingDown, float firstCorpseX,
|
||||
boolean playerOnGround, float playerVelocityX,
|
||||
float playerVelocityY, boolean firstHazardActive,
|
||||
float firstHazardX, float firstHazardY) {
|
||||
this.completed = completed;
|
||||
this.playerAlive = playerAlive;
|
||||
this.allDoorsOpen = allDoorsOpen;
|
||||
@@ -158,6 +184,9 @@ public final class GameView extends View implements Runnable {
|
||||
this.playerOnGround = playerOnGround;
|
||||
this.playerVelocityX = playerVelocityX;
|
||||
this.playerVelocityY = playerVelocityY;
|
||||
this.firstHazardActive = firstHazardActive;
|
||||
this.firstHazardX = firstHazardX;
|
||||
this.firstHazardY = firstHazardY;
|
||||
}
|
||||
|
||||
public boolean completed() { return completed; }
|
||||
@@ -181,6 +210,9 @@ public final class GameView extends View implements Runnable {
|
||||
public boolean playerOnGround() { return playerOnGround; }
|
||||
public float playerVelocityX() { return playerVelocityX; }
|
||||
public float playerVelocityY() { return playerVelocityY; }
|
||||
public boolean firstHazardActive() { return firstHazardActive; }
|
||||
public float firstHazardX() { return firstHazardX; }
|
||||
public float firstHazardY() { return firstHazardY; }
|
||||
}
|
||||
|
||||
private static final class Player {
|
||||
@@ -229,6 +261,7 @@ public final class GameView extends View implements Runnable {
|
||||
private final CorpseManager corpseManager = new CorpseManager(MAX_CORPSES);
|
||||
private final List<Body> corpses = corpseManager.bodies();
|
||||
private final ArrayList<PlatformState> platformStates = new ArrayList<>();
|
||||
private final ArrayList<HazardRuntime> hazardRuntimes = new ArrayList<>();
|
||||
private final Map<String, Boolean> previousSwitchContact = new HashMap<>();
|
||||
private final Map<LevelDefinition.Door, DoorController> doorControllers =
|
||||
new IdentityHashMap<>();
|
||||
@@ -240,6 +273,8 @@ public final class GameView extends View implements Runnable {
|
||||
private final CameraController cameraController =
|
||||
new CameraController(VIEW_WIDTH, VIEW_HEIGHT);
|
||||
private final PerformanceMonitor performanceMonitor = new PerformanceMonitor(240);
|
||||
private final LevelPage levelPage =
|
||||
new LevelPage(LevelCatalog.all().size(), LEVELS_PER_PAGE);
|
||||
|
||||
private Screen screen = Screen.LEVELS;
|
||||
private LevelDefinition level;
|
||||
@@ -394,12 +429,16 @@ public final class GameView extends View implements Runnable {
|
||||
deriveControls();
|
||||
corpseManager.reset();
|
||||
platformStates.clear();
|
||||
hazardRuntimes.clear();
|
||||
previousSwitchContact.clear();
|
||||
configurePuzzleSystem();
|
||||
completionResult = null;
|
||||
configureAccessibilitySignals();
|
||||
invalidateHudTextCache();
|
||||
for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item));
|
||||
for (LevelDefinition.Hazard item : level.hazards) {
|
||||
hazardRuntimes.add(new HazardRuntime(item));
|
||||
}
|
||||
spawnPlayer();
|
||||
lastFrameNanos = System.nanoTime();
|
||||
refreshAccessibilityDescription(true);
|
||||
@@ -428,6 +467,7 @@ public final class GameView extends View implements Runnable {
|
||||
private void update(float dt) {
|
||||
levelElapsed += dt;
|
||||
audioEngine.updateMusic(levelElapsed, level.number);
|
||||
for (HazardRuntime runtime : hazardRuntimes) runtime.update(dt);
|
||||
updateSignals(dt);
|
||||
updatePlatforms(dt);
|
||||
|
||||
@@ -769,14 +809,22 @@ public final class GameView extends View implements Runnable {
|
||||
}
|
||||
|
||||
private void checkHazards() {
|
||||
for (LevelDefinition.Hazard hazard : level.hazards) {
|
||||
if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, hazard.box)) continue;
|
||||
for (int index = 0; index < level.hazards.size(); index++) {
|
||||
LevelDefinition.Hazard hazard = level.hazards.get(index);
|
||||
HazardRuntime runtime = hazardRuntimes.get(index);
|
||||
if (!runtime.active()) continue;
|
||||
if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT,
|
||||
runtime.currentBox())) continue;
|
||||
switch (hazard.type) {
|
||||
case SPIKES: kill(DeathType.SPIKE, hazard); return;
|
||||
case SAW: kill(DeathType.SAW, hazard); return;
|
||||
case FREEZER: kill(DeathType.FREEZE, hazard); return;
|
||||
case ELECTRIC: kill(DeathType.ELECTRIC, hazard); return;
|
||||
case PIT: kill(DeathType.PIT, hazard); return;
|
||||
case CRUSHER: kill(DeathType.CRUSH, hazard); return;
|
||||
case FIRE: kill(DeathType.FIRE, hazard); return;
|
||||
case PROJECTILE: kill(DeathType.PROJECTILE, hazard); return;
|
||||
case EXPLOSION: kill(DeathType.EXPLOSION, hazard); return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -792,7 +840,7 @@ 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(hazardAudioCue(source));
|
||||
audioEngine.play(AudioCue.DEATH_VOICE);
|
||||
cameraController.impact(type == DeathType.PIT ? 12f : 24f);
|
||||
|
||||
@@ -824,6 +872,22 @@ public final class GameView extends View implements Runnable {
|
||||
}
|
||||
}
|
||||
|
||||
private static AudioCue hazardAudioCue(LevelDefinition.Hazard source) {
|
||||
if (source != null && "crusher-impact".equals(source.audioCue)) {
|
||||
return AudioCue.CRUSHER_IMPACT;
|
||||
}
|
||||
if (source != null && "fire-burst".equals(source.audioCue)) {
|
||||
return AudioCue.FIRE_BURST;
|
||||
}
|
||||
if (source != null && "projectile-hit".equals(source.audioCue)) {
|
||||
return AudioCue.PROJECTILE_HIT;
|
||||
}
|
||||
if (source != null && "explosion-blast".equals(source.audioCue)) {
|
||||
return AudioCue.EXPLOSION_BLAST;
|
||||
}
|
||||
return AudioCue.DEATH_IMPACT;
|
||||
}
|
||||
|
||||
private void checkGoal() {
|
||||
if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, level.goal)) return;
|
||||
finishLevel(deaths, (long) (levelElapsed * 1000f));
|
||||
@@ -1041,8 +1105,9 @@ public final class GameView extends View implements Runnable {
|
||||
}
|
||||
|
||||
private void drawHazards(Canvas canvas) {
|
||||
for (LevelDefinition.Hazard hazard : level.hazards) {
|
||||
RectF box = rect(hazard.box);
|
||||
for (int index = 0; index < level.hazards.size(); index++) {
|
||||
LevelDefinition.Hazard hazard = level.hazards.get(index);
|
||||
RectF box = rect(hazardRuntimes.get(index).currentBox());
|
||||
switch (hazard.type) {
|
||||
case SPIKES:
|
||||
paint.setColor(Color.rgb(194, 201, 211));
|
||||
@@ -1088,10 +1153,88 @@ public final class GameView extends View implements Runnable {
|
||||
paint.setColor(Color.argb(80, 132, 87, 170));
|
||||
for (float x = box.left + 30; x < box.right; x += 80) canvas.drawOval(new RectF(x, box.top + 35, x + 30, box.top + 80), paint);
|
||||
break;
|
||||
case CRUSHER:
|
||||
drawCrusher(canvas, box, hazardRuntimes.get(index).active(),
|
||||
hazard.visualStyle);
|
||||
break;
|
||||
case FIRE:
|
||||
drawFire(canvas, box, hazard.visualStyle);
|
||||
break;
|
||||
case PROJECTILE:
|
||||
drawProjectile(canvas, box, hazard.visualStyle);
|
||||
break;
|
||||
case EXPLOSION:
|
||||
drawExplosion(canvas, box, hazardRuntimes.get(index).active(),
|
||||
hazard.visualStyle);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void drawCrusher(Canvas canvas, RectF box, boolean active, String visualStyle) {
|
||||
boolean brass = "brass-press".equals(visualStyle);
|
||||
paint.setColor(accessibilitySettings.highContrastEnabled()
|
||||
? Color.BLACK : (brass ? Color.rgb(91, 66, 38) : Color.rgb(72, 76, 88)));
|
||||
canvas.drawRoundRect(box, 12f, 12f, paint);
|
||||
float jawHeight = active ? box.height() * 0.48f : box.height() * 0.18f;
|
||||
paint.setColor(accessibilitySettings.highContrastEnabled()
|
||||
? Color.WHITE : (brass ? Color.rgb(211, 157, 67) : Color.rgb(174, 180, 192)));
|
||||
canvas.drawRect(box.left + 8f, box.top + 8f,
|
||||
box.right - 8f, box.top + jawHeight, paint);
|
||||
canvas.drawRect(box.left + 8f, box.bottom - jawHeight,
|
||||
box.right - 8f, box.bottom - 8f, paint);
|
||||
drawWorldLabel(canvas, active ? "CRUSH" : "WAIT", box.centerX(), box.top - 20f);
|
||||
}
|
||||
|
||||
private void drawFire(Canvas canvas, RectF box, String visualStyle) {
|
||||
boolean blue = "blue-flame".equals(visualStyle);
|
||||
paint.setColor(Color.argb(110, blue ? 54 : 236, blue ? 182 : 90,
|
||||
blue ? 255 : 45));
|
||||
canvas.drawRoundRect(box, 16f, 16f, paint);
|
||||
float wave = accessibilitySettings.reducedMotionEnabled()
|
||||
? 0f : (float) Math.sin(levelElapsed * 7f) * 10f;
|
||||
paint.setColor(accessibilitySettings.highContrastEnabled()
|
||||
? Color.WHITE : (blue ? Color.rgb(116, 229, 255) : Color.rgb(255, 190, 64)));
|
||||
for (float x = box.left + 18f; x < box.right; x += 34f) {
|
||||
path.reset();
|
||||
path.moveTo(x - 14f, box.bottom - 6f);
|
||||
path.lineTo(x + wave * 0.25f, box.top + 12f);
|
||||
path.lineTo(x + 14f, box.bottom - 6f);
|
||||
path.close();
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawProjectile(Canvas canvas, RectF box, String visualStyle) {
|
||||
boolean clockwork = "clockwork-bolt".equals(visualStyle);
|
||||
paint.setColor(accessibilitySettings.highContrastEnabled()
|
||||
? Color.WHITE : (clockwork ? Color.rgb(233, 181, 76) : Color.rgb(204, 211, 221)));
|
||||
canvas.drawCircle(box.centerX(), box.centerY(), box.width() * 0.46f, paint);
|
||||
stroke.setColor(accessibilitySettings.highContrastEnabled()
|
||||
? Color.BLACK : Color.rgb(72, 56, 42));
|
||||
stroke.setStrokeWidth(5f);
|
||||
canvas.drawLine(box.left + 8f, box.centerY(), box.right - 8f, box.centerY(), stroke);
|
||||
canvas.drawLine(box.centerX(), box.top + 8f, box.centerX(), box.bottom - 8f, stroke);
|
||||
}
|
||||
|
||||
private void drawExplosion(Canvas canvas, RectF box, boolean active, String visualStyle) {
|
||||
boolean steam = "steam-blast".equals(visualStyle);
|
||||
float pulse = accessibilitySettings.reducedMotionEnabled()
|
||||
? 0f : (float) Math.sin(levelElapsed * 5f) * 8f;
|
||||
paint.setColor(Color.argb(active ? 95 : 28, steam ? 195 : 255, steam ? 220 : 124,
|
||||
steam ? 231 : 52));
|
||||
canvas.drawCircle(box.centerX(), box.centerY(),
|
||||
Math.max(12f, box.width() * (active ? 0.47f : 0.2f) + pulse), paint);
|
||||
stroke.setColor(accessibilitySettings.highContrastEnabled()
|
||||
? Color.WHITE : Color.rgb(255, 218, 118));
|
||||
stroke.setStrokeWidth(7f);
|
||||
canvas.drawLine(box.left + 12f, box.top + 12f,
|
||||
box.right - 12f, box.bottom - 12f, stroke);
|
||||
canvas.drawLine(box.right - 12f, box.top + 12f,
|
||||
box.left + 12f, box.bottom - 12f, stroke);
|
||||
drawWorldLabel(canvas, active ? "BLAST" : "REST", box.centerX(), box.top - 20f);
|
||||
}
|
||||
|
||||
|
||||
private void drawSaw(Canvas canvas, RectF box) {
|
||||
float angle = levelElapsed * 4.5f;
|
||||
@@ -1241,9 +1384,10 @@ public final class GameView extends View implements Runnable {
|
||||
float cardW = 310f;
|
||||
float cardH = 250f;
|
||||
float gapX = 65f;
|
||||
for (int i = 0; i < LevelCatalog.all().size(); i++) {
|
||||
int col = i % 5;
|
||||
int row = i / 5;
|
||||
for (int i = levelPage.firstIndex(); i < levelPage.endExclusive(); i++) {
|
||||
int localIndex = i - levelPage.firstIndex();
|
||||
int col = localIndex % 5;
|
||||
int row = localIndex / 5;
|
||||
float left = startX + col * (cardW + gapX);
|
||||
float top = 260f + row * 310f;
|
||||
RectF card = new RectF(left, top, left + cardW, top + cardH);
|
||||
@@ -1278,7 +1422,16 @@ public final class GameView extends View implements Runnable {
|
||||
canvas.restore();
|
||||
}
|
||||
drawHudButton(canvas, new RectF(1560, 70, 1850, 160), strings.settingsButton(), false);
|
||||
drawCenteredText(canvas, strings.levelSelectFooter(), VIEW_WIDTH * 0.5f, 980, 24,
|
||||
if (levelPage.hasPrevious()) {
|
||||
drawHudButton(canvas, new RectF(50, 920, 330, 1010), strings.previousPage(), false);
|
||||
}
|
||||
if (levelPage.hasNext()) {
|
||||
drawHudButton(canvas, new RectF(1590, 920, 1870, 1010), strings.nextPage(), false);
|
||||
}
|
||||
drawCenteredText(canvas, strings.pageIndicator(levelPage.pageIndex() + 1,
|
||||
levelPage.pageCount()), VIEW_WIDTH * 0.5f, 965, 25,
|
||||
Color.rgb(248, 212, 126));
|
||||
drawCenteredText(canvas, strings.levelSelectFooter(), VIEW_WIDTH * 0.5f, 1042, 22,
|
||||
Color.argb(190, 230, 230, 236));
|
||||
}
|
||||
|
||||
@@ -1651,13 +1804,26 @@ public final class GameView extends View implements Runnable {
|
||||
refreshAccessibilityDescription(true);
|
||||
return;
|
||||
}
|
||||
if (levelPage.hasPrevious() && contains(50, 920, 330, 1010, x, y)) {
|
||||
levelPage.previous();
|
||||
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
|
||||
refreshAccessibilityDescription(true);
|
||||
return;
|
||||
}
|
||||
if (levelPage.hasNext() && contains(1590, 920, 1870, 1010, x, y)) {
|
||||
levelPage.next();
|
||||
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
|
||||
refreshAccessibilityDescription(true);
|
||||
return;
|
||||
}
|
||||
float startX = 110f;
|
||||
float cardW = 310f;
|
||||
float cardH = 250f;
|
||||
float gapX = 65f;
|
||||
for (int i = 0; i < LevelCatalog.all().size(); i++) {
|
||||
int col = i % 5;
|
||||
int row = i / 5;
|
||||
for (int i = levelPage.firstIndex(); i < levelPage.endExclusive(); i++) {
|
||||
int localIndex = i - levelPage.firstIndex();
|
||||
int col = localIndex % 5;
|
||||
int row = localIndex / 5;
|
||||
float left = startX + col * (cardW + gapX);
|
||||
float top = 260f + row * 310f;
|
||||
if (contains(left, top, left + cardW, top + cardH, x, y) && i < progress.unlockedCount()) {
|
||||
@@ -1695,6 +1861,7 @@ public final class GameView extends View implements Runnable {
|
||||
|
||||
private void levelsFromResult() {
|
||||
if (screen != Screen.COMPLETE) return;
|
||||
levelPage.selectIndex(levelIndex);
|
||||
screen = Screen.LEVELS;
|
||||
pointers.clear();
|
||||
deriveControls();
|
||||
@@ -1906,6 +2073,24 @@ public final class GameView extends View implements Runnable {
|
||||
levelsFromResult();
|
||||
}
|
||||
|
||||
public int levelPageIndexForAutomation() {
|
||||
return levelPage.pageIndex();
|
||||
}
|
||||
|
||||
public int levelPageCountForAutomation() {
|
||||
return levelPage.pageCount();
|
||||
}
|
||||
|
||||
public void nextLevelPageForAutomation() {
|
||||
levelPage.next();
|
||||
refreshAccessibilityDescription(true);
|
||||
}
|
||||
|
||||
public void previousLevelPageForAutomation() {
|
||||
levelPage.previous();
|
||||
refreshAccessibilityDescription(true);
|
||||
}
|
||||
|
||||
public void replayFromResultForAutomation() {
|
||||
replayFromResult();
|
||||
}
|
||||
@@ -1936,7 +2121,7 @@ public final class GameView extends View implements Runnable {
|
||||
if (screen == Screen.LEVELS) {
|
||||
visible.add(strings.projectTitle());
|
||||
visible.add(strings.tagline());
|
||||
for (int i = 0; i < LevelCatalog.all().size(); i++) {
|
||||
for (int i = levelPage.firstIndex(); i < levelPage.endExclusive(); i++) {
|
||||
boolean enabled = i < progress.unlockedCount();
|
||||
visible.add(enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked());
|
||||
visible.add(strings.levelTitle(i + 1));
|
||||
@@ -1948,6 +2133,10 @@ public final class GameView extends View implements Runnable {
|
||||
}
|
||||
}
|
||||
visible.add(strings.settingsButton());
|
||||
if (levelPage.hasPrevious()) visible.add(strings.previousPage());
|
||||
if (levelPage.hasNext()) visible.add(strings.nextPage());
|
||||
visible.add(strings.pageIndicator(levelPage.pageIndex() + 1,
|
||||
levelPage.pageCount()));
|
||||
visible.add(strings.levelSelectFooter());
|
||||
} else if (screen == Screen.SETTINGS) {
|
||||
visible.add(strings.settingsTitle());
|
||||
@@ -2344,6 +2533,7 @@ public final class GameView extends View implements Runnable {
|
||||
|
||||
startLevel(index);
|
||||
levelElapsed = snapshot.elapsedSeconds();
|
||||
for (HazardRuntime runtime : hazardRuntimes) runtime.update(levelElapsed);
|
||||
deaths = snapshot.deaths();
|
||||
respawnTimer = snapshot.respawnSeconds();
|
||||
previousSwitchContact.clear();
|
||||
@@ -2436,6 +2626,16 @@ public final class GameView extends View implements Runnable {
|
||||
&& player.supportIndex >= 0
|
||||
&& player.supportIndex < platformStates.size()
|
||||
&& platformStates.get(player.supportIndex).definition.isMoving();
|
||||
boolean firstHazardActive = false;
|
||||
float firstHazardX = Float.NaN;
|
||||
float firstHazardY = Float.NaN;
|
||||
if (!hazardRuntimes.isEmpty()) {
|
||||
HazardRuntime firstHazard = hazardRuntimes.get(0);
|
||||
LevelDefinition.Box firstHazardBox = firstHazard.currentBox();
|
||||
firstHazardActive = firstHazard.active();
|
||||
firstHazardX = firstHazardBox.x;
|
||||
firstHazardY = firstHazardBox.y;
|
||||
}
|
||||
return new AutomationSnapshot(
|
||||
screen == Screen.COMPLETE,
|
||||
player.alive,
|
||||
@@ -2457,7 +2657,10 @@ public final class GameView extends View implements Runnable {
|
||||
corpses.isEmpty() ? Float.NaN : corpses.get(0).x,
|
||||
player.onGround,
|
||||
player.vx,
|
||||
player.vy
|
||||
player.vy,
|
||||
firstHazardActive,
|
||||
firstHazardX,
|
||||
firstHazardY
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Deterministic per-level runtime clock for one immutable hazard definition. */
|
||||
public final class HazardRuntime {
|
||||
private final LevelDefinition.Hazard definition;
|
||||
private float elapsedSeconds;
|
||||
|
||||
public HazardRuntime(LevelDefinition.Hazard definition) {
|
||||
if (definition == null) throw new IllegalArgumentException("definition is required");
|
||||
this.definition = definition;
|
||||
}
|
||||
|
||||
public LevelDefinition.Hazard definition() { return definition; }
|
||||
public float elapsedSeconds() { return elapsedSeconds; }
|
||||
|
||||
public void update(float deltaSeconds) {
|
||||
if (!Float.isFinite(deltaSeconds) || deltaSeconds < 0f) {
|
||||
throw new IllegalArgumentException("deltaSeconds must be finite and non-negative");
|
||||
}
|
||||
elapsedSeconds += deltaSeconds;
|
||||
if (definition.cycleSeconds > 0f && elapsedSeconds >= definition.cycleSeconds) {
|
||||
elapsedSeconds %= definition.cycleSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean active() {
|
||||
boolean cyclicWindow = definition.type == LevelDefinition.HazardType.CRUSHER
|
||||
|| definition.type == LevelDefinition.HazardType.EXPLOSION;
|
||||
if (!cyclicWindow || definition.cycleSeconds <= 0f
|
||||
|| definition.activeSeconds <= 0f) return true;
|
||||
return elapsedSeconds < definition.activeSeconds;
|
||||
}
|
||||
|
||||
public LevelDefinition.Box currentBox() {
|
||||
if (definition.type != LevelDefinition.HazardType.PROJECTILE) {
|
||||
return definition.box;
|
||||
}
|
||||
return new LevelDefinition.Box(
|
||||
definition.box.x + definition.speedX * elapsedSeconds,
|
||||
definition.box.y + definition.speedY * elapsedSeconds,
|
||||
definition.box.width, definition.box.height);
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
elapsedSeconds = 0f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
public final class LevelPage {
|
||||
private final int totalItems;
|
||||
private final int pageSize;
|
||||
private int pageIndex;
|
||||
|
||||
public LevelPage(int totalItems, int pageSize) {
|
||||
if (totalItems < 1) {
|
||||
throw new IllegalArgumentException("totalItems must be positive");
|
||||
}
|
||||
if (pageSize < 1) {
|
||||
throw new IllegalArgumentException("pageSize must be positive");
|
||||
}
|
||||
this.totalItems = totalItems;
|
||||
this.pageSize = pageSize;
|
||||
}
|
||||
|
||||
public int pageIndex() {
|
||||
return pageIndex;
|
||||
}
|
||||
|
||||
public int pageCount() {
|
||||
return (totalItems + pageSize - 1) / pageSize;
|
||||
}
|
||||
|
||||
public int firstIndex() {
|
||||
return pageIndex * pageSize;
|
||||
}
|
||||
|
||||
public int endExclusive() {
|
||||
return Math.min(totalItems, firstIndex() + pageSize);
|
||||
}
|
||||
|
||||
public boolean hasPrevious() {
|
||||
return pageIndex > 0;
|
||||
}
|
||||
|
||||
public boolean hasNext() {
|
||||
return pageIndex + 1 < pageCount();
|
||||
}
|
||||
|
||||
public void previous() {
|
||||
if (hasPrevious()) {
|
||||
pageIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
public void next() {
|
||||
if (hasNext()) {
|
||||
pageIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
public void selectIndex(int index) {
|
||||
if (index < 0 || index >= totalItems) {
|
||||
throw new IllegalArgumentException("index outside level page");
|
||||
}
|
||||
pageIndex = index / pageSize;
|
||||
}
|
||||
}
|
||||
@@ -257,6 +257,8 @@ public final class LevelPlaytestBot {
|
||||
+ ", completed=" + snapshot.completed()
|
||||
+ ", deaths=" + snapshot.deaths()
|
||||
+ ", corpses=" + snapshot.corpseCount()
|
||||
+ ", firstCorpseX=" + snapshot.firstCorpseX()
|
||||
+ ", firstHazardX=" + snapshot.firstHazardX()
|
||||
+ ", allDoorsOpen=" + snapshot.allDoorsOpen();
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,18 @@ final class ProceduralAudioEngine {
|
||||
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 CRUSHER_IMPACT: return variant == 0 ? ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD
|
||||
: variant == 1 ? ToneGenerator.TONE_CDMA_ABBR_INTERCEPT
|
||||
: ToneGenerator.TONE_CDMA_SOFT_ERROR_LITE;
|
||||
case FIRE_BURST: return variant == 0 ? ToneGenerator.TONE_CDMA_ALERT_AUTOREDIAL_LITE
|
||||
: variant == 1 ? ToneGenerator.TONE_CDMA_ALERT_NETWORK_LITE
|
||||
: ToneGenerator.TONE_CDMA_ALERT_INCALL_LITE;
|
||||
case PROJECTILE_HIT: return variant == 0 ? ToneGenerator.TONE_SUP_ERROR
|
||||
: variant == 1 ? ToneGenerator.TONE_SUP_CONGESTION
|
||||
: ToneGenerator.TONE_SUP_RADIO_ACK;
|
||||
case EXPLOSION_BLAST: return variant == 0 ? ToneGenerator.TONE_CDMA_HIGH_PBX_L
|
||||
: variant == 1 ? ToneGenerator.TONE_CDMA_MED_PBX_L
|
||||
: ToneGenerator.TONE_CDMA_LOW_PBX_L;
|
||||
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
|
||||
@@ -126,6 +138,10 @@ final class ProceduralAudioEngine {
|
||||
case SWITCH: return 85 + variant * 20;
|
||||
case DOOR: return 180;
|
||||
case DEATH_IMPACT: return 130 + variant * 18;
|
||||
case CRUSHER_IMPACT: return 170 + variant * 35;
|
||||
case FIRE_BURST: return 145 + variant * 28;
|
||||
case PROJECTILE_HIT: return 105 + variant * 22;
|
||||
case EXPLOSION_BLAST: return 230 + variant * 40;
|
||||
case DEATH_VOICE: return 260 + variant * 45;
|
||||
case RESPAWN_VOICE: return 175 + variant * 35;
|
||||
case GOAL_VOICE: return 320 + variant * 55;
|
||||
|
||||
@@ -64,7 +64,9 @@ public final class VersionedSessionCodec {
|
||||
if (version < 1) return result(Status.CORRUPT, null);
|
||||
|
||||
int levelNumber = input.readInt();
|
||||
if (levelNumber < 1 || levelNumber > 10) return result(Status.CORRUPT, null);
|
||||
if (levelNumber < 1 || levelNumber > LevelCatalog.all().size()) {
|
||||
return result(Status.CORRUPT, null);
|
||||
}
|
||||
GameSessionSnapshot.Builder builder = GameSessionSnapshot.builder(levelNumber)
|
||||
.saveVersion(CURRENT_VERSION)
|
||||
.elapsedSeconds(finiteNonNegative(input.readFloat()))
|
||||
|
||||
@@ -45,6 +45,15 @@ public final class DeterministicInputTapeTest {
|
||||
ReplayStateSignature.of(puzzle, false, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void productionTeachingLevelsCanOwnVersionedInputTapes() {
|
||||
DeterministicInputTape tape = DeterministicInputTape.builder(14)
|
||||
.record(false, true, true, 250L)
|
||||
.build();
|
||||
|
||||
assertEquals(14, tape.levelNumber());
|
||||
}
|
||||
|
||||
private static final class FakeTarget implements DeterministicInputTape.Target {
|
||||
private int level;
|
||||
private long ticks;
|
||||
|
||||
@@ -27,13 +27,16 @@ public final class GameStringsTest {
|
||||
assertEquals("Русский", russian.languageName());
|
||||
assertEquals("Рыцарь-доброволец", russian.levelTitle(1));
|
||||
assertEquals("Последнее шествие", russian.levelTitle(10));
|
||||
assertEquals("Паровой заряд", russian.levelTitle(14));
|
||||
assertEquals("Page 2 of 2", english.pageIndicator(2, 2));
|
||||
assertEquals("Страница 2 из 2", russian.pageIndicator(2, 2));
|
||||
assertEquals("Добровольцы 2 Время 00:07", russian.hudStats(2, 7_000L));
|
||||
assertEquals("✓ 2 добровольца · 00:07", russian.levelBestStats(2, 7_000L));
|
||||
assertEquals("★ 2 / 3 добровольца", russian.completionDeaths(true, 2, 3));
|
||||
assertEquals("Смерть — это инструмент", russian.tutorialWelcomeTitle());
|
||||
assertFalse(russian.tutorialSacrificeBody().trim().isEmpty());
|
||||
|
||||
for (int level = 1; level <= 10; level++) {
|
||||
for (int level = 1; level <= LevelCatalog.all().size(); level++) {
|
||||
assertFalse(russian.levelTitle(level).trim().isEmpty());
|
||||
assertFalse(russian.levelHint(level).trim().isEmpty());
|
||||
assertNotEquals(english.levelTitle(level), russian.levelTitle(level));
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class HazardRuntimeTest {
|
||||
@Test
|
||||
public void crusherActiveWindowRepeatsOnItsDataDrivenCycle() {
|
||||
LevelDefinition.Hazard definition = new LevelDefinition.Hazard(
|
||||
LevelDefinition.HazardType.CRUSHER,
|
||||
new LevelDefinition.Box(620f, 690f, 150f, 190f),
|
||||
null, 0f, 180f, 4f, 1.25f, 0f, 0f,
|
||||
"brass-press", "crusher-impact");
|
||||
HazardRuntime runtime = new HazardRuntime(definition);
|
||||
|
||||
assertTrue(runtime.active());
|
||||
runtime.update(1.2f);
|
||||
assertTrue(runtime.active());
|
||||
runtime.update(0.1f);
|
||||
assertFalse(runtime.active());
|
||||
runtime.update(2.7f);
|
||||
assertTrue(runtime.active());
|
||||
runtime.update(4f);
|
||||
assertTrue(runtime.active());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectileMovesFromItsSpawnBoxAndWrapsAtTheConfiguredCycle() {
|
||||
LevelDefinition.Hazard definition = new LevelDefinition.Hazard(
|
||||
LevelDefinition.HazardType.PROJECTILE,
|
||||
new LevelDefinition.Box(620f, 790f, 48f, 48f),
|
||||
null, 420f, -120f, 3f, 0f, 360f, 0f,
|
||||
"clockwork-bolt", "projectile-hit");
|
||||
HazardRuntime runtime = new HazardRuntime(definition);
|
||||
|
||||
runtime.update(1f);
|
||||
assertTrue(runtime.currentBox().x == 980f);
|
||||
runtime.update(2f);
|
||||
assertTrue(runtime.currentBox().x == 620f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explosionHasADataDrivenBlastAndRestWindow() {
|
||||
LevelDefinition.Hazard definition = new LevelDefinition.Hazard(
|
||||
LevelDefinition.HazardType.EXPLOSION,
|
||||
new LevelDefinition.Box(620f, 740f, 140f, 140f),
|
||||
null, 550f, -500f, 4f, 2f, 0f, 0f,
|
||||
"steam-blast", "explosion-blast");
|
||||
HazardRuntime runtime = new HazardRuntime(definition);
|
||||
|
||||
assertTrue(runtime.active());
|
||||
runtime.update(2.1f);
|
||||
assertFalse(runtime.active());
|
||||
runtime.update(1.9f);
|
||||
assertTrue(runtime.active());
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,9 @@ import java.util.Set;
|
||||
|
||||
public final class LevelCatalogTest {
|
||||
@Test
|
||||
public void verticalSliceContainsTenSequentialLevels() {
|
||||
public void productionCatalogContainsFourHazardTeachingLevelsAfterTheVerticalSlice() {
|
||||
List<LevelDefinition> levels = LevelCatalog.all();
|
||||
assertEquals(10, levels.size());
|
||||
assertEquals(14, levels.size());
|
||||
Set<String> titles = new HashSet<>();
|
||||
for (int i = 0; i < levels.size(); i++) {
|
||||
LevelDefinition level = levels.get(i);
|
||||
@@ -22,6 +22,10 @@ public final class LevelCatalogTest {
|
||||
assertTrue(titles.add(level.title));
|
||||
assertFalse(level.hint.trim().isEmpty());
|
||||
}
|
||||
assertTrue(levels.get(10).containsMechanic(LevelDefinition.HazardType.CRUSHER));
|
||||
assertTrue(levels.get(11).containsMechanic(LevelDefinition.HazardType.FIRE));
|
||||
assertTrue(levels.get(12).containsMechanic(LevelDefinition.HazardType.PROJECTILE));
|
||||
assertTrue(levels.get(13).containsMechanic(LevelDefinition.HazardType.EXPLOSION));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -126,6 +126,94 @@ public final class LevelJsonLoaderTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void crusherLoadsItsCycleImpulseAudioAndVisualSettingsFromData() {
|
||||
String json = validLevelJson(1).replace(
|
||||
"\"hazards\":[],\"switches\":[],\"doors\":[]",
|
||||
"\"hazards\":[{\"type\":\"CRUSHER\","
|
||||
+ "\"box\":{\"x\":620,\"y\":690,\"width\":150,\"height\":190},"
|
||||
+ "\"impulseX\":0,\"impulseY\":180,"
|
||||
+ "\"cycleSeconds\":4,\"activeSeconds\":1.25,"
|
||||
+ "\"speedX\":0,\"speedY\":0,"
|
||||
+ "\"visualStyle\":\"brass-press\",\"audioCue\":\"crusher-impact\"}],"
|
||||
+ "\"switches\":[],\"doors\":[]");
|
||||
|
||||
LevelDefinition.Hazard crusher = LevelJsonLoader.load(
|
||||
new StringReader(json), "crusher.json").hazards.get(0);
|
||||
|
||||
assertEquals(LevelDefinition.HazardType.CRUSHER, crusher.type);
|
||||
assertEquals(4f, crusher.cycleSeconds, 0f);
|
||||
assertEquals(1.25f, crusher.activeSeconds, 0f);
|
||||
assertEquals(180f, crusher.impulseY, 0f);
|
||||
assertEquals("brass-press", crusher.visualStyle);
|
||||
assertEquals("crusher-impact", crusher.audioCue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fireLoadsItsCorpseImpulseAudioAndVisualSettingsFromData() {
|
||||
String json = validLevelJson(1).replace(
|
||||
"\"hazards\":[],\"switches\":[],\"doors\":[]",
|
||||
"\"hazards\":[{\"type\":\"FIRE\","
|
||||
+ "\"box\":{\"x\":700,\"y\":790,\"width\":120,\"height\":90},"
|
||||
+ "\"impulseX\":160,\"impulseY\":-220,"
|
||||
+ "\"visualStyle\":\"blue-flame\",\"audioCue\":\"fire-burst\"}],"
|
||||
+ "\"switches\":[],\"doors\":[]");
|
||||
|
||||
LevelDefinition.Hazard fire = LevelJsonLoader.load(
|
||||
new StringReader(json), "fire.json").hazards.get(0);
|
||||
|
||||
assertEquals(LevelDefinition.HazardType.FIRE, fire.type);
|
||||
assertEquals(160f, fire.impulseX, 0f);
|
||||
assertEquals(-220f, fire.impulseY, 0f);
|
||||
assertEquals("blue-flame", fire.visualStyle);
|
||||
assertEquals("fire-burst", fire.audioCue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectileLoadsItsVelocityCycleAudioAndVisualSettingsFromData() {
|
||||
String json = validLevelJson(1).replace(
|
||||
"\"hazards\":[],\"switches\":[],\"doors\":[]",
|
||||
"\"hazards\":[{\"type\":\"PROJECTILE\","
|
||||
+ "\"box\":{\"x\":620,\"y\":790,\"width\":48,\"height\":48},"
|
||||
+ "\"impulseX\":420,\"impulseY\":-120,"
|
||||
+ "\"cycleSeconds\":3,\"activeSeconds\":0,"
|
||||
+ "\"speedX\":360,\"speedY\":0,"
|
||||
+ "\"visualStyle\":\"clockwork-bolt\",\"audioCue\":\"projectile-hit\"}],"
|
||||
+ "\"switches\":[],\"doors\":[]");
|
||||
|
||||
LevelDefinition.Hazard projectile = LevelJsonLoader.load(
|
||||
new StringReader(json), "projectile.json").hazards.get(0);
|
||||
|
||||
assertEquals(LevelDefinition.HazardType.PROJECTILE, projectile.type);
|
||||
assertEquals(3f, projectile.cycleSeconds, 0f);
|
||||
assertEquals(360f, projectile.speedX, 0f);
|
||||
assertEquals("clockwork-bolt", projectile.visualStyle);
|
||||
assertEquals("projectile-hit", projectile.audioCue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explosionLoadsItsRequiredImpulseAudioAndVisualSettingsFromData() {
|
||||
String json = validLevelJson(1).replace(
|
||||
"\"hazards\":[],\"switches\":[],\"doors\":[]",
|
||||
"\"hazards\":[{\"type\":\"EXPLOSION\","
|
||||
+ "\"box\":{\"x\":720,\"y\":740,\"width\":140,\"height\":140},"
|
||||
+ "\"impulseX\":520,\"impulseY\":-380,"
|
||||
+ "\"cycleSeconds\":4,\"activeSeconds\":2,"
|
||||
+ "\"visualStyle\":\"steam-blast\",\"audioCue\":\"explosion-blast\"}],"
|
||||
+ "\"switches\":[],\"doors\":[]");
|
||||
|
||||
LevelDefinition.Hazard explosion = LevelJsonLoader.load(
|
||||
new StringReader(json), "explosion.json").hazards.get(0);
|
||||
|
||||
assertEquals(LevelDefinition.HazardType.EXPLOSION, explosion.type);
|
||||
assertEquals(520f, explosion.impulseX, 0f);
|
||||
assertEquals(-380f, explosion.impulseY, 0f);
|
||||
assertEquals(4f, explosion.cycleSeconds, 0f);
|
||||
assertEquals(2f, explosion.activeSeconds, 0f);
|
||||
assertEquals("steam-blast", explosion.visualStyle);
|
||||
assertEquals("explosion-blast", explosion.audioCue);
|
||||
}
|
||||
|
||||
static String validLevelJson(int formatVersion) {
|
||||
return "{"
|
||||
+ "\"formatVersion\":" + formatVersion + ","
|
||||
|
||||
@@ -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 LevelPageTest {
|
||||
@Test
|
||||
public void fourteenLevelsUseTwoBoundedTenCardPages() {
|
||||
LevelPage page = new LevelPage(14, 10);
|
||||
|
||||
assertEquals(2, page.pageCount());
|
||||
assertEquals(0, page.firstIndex());
|
||||
assertEquals(10, page.endExclusive());
|
||||
assertFalse(page.hasPrevious());
|
||||
assertTrue(page.hasNext());
|
||||
|
||||
page.next();
|
||||
assertEquals(10, page.firstIndex());
|
||||
assertEquals(14, page.endExclusive());
|
||||
assertTrue(page.hasPrevious());
|
||||
assertFalse(page.hasNext());
|
||||
|
||||
page.next();
|
||||
assertEquals(1, page.pageIndex());
|
||||
page.selectIndex(3);
|
||||
assertEquals(0, page.pageIndex());
|
||||
page.selectIndex(13);
|
||||
assertEquals(1, page.pageIndex());
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,22 @@ public final class VersionedSessionCodecTest {
|
||||
assertNull(future.snapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void productionTeachingLevelSnapshotRoundTrips() {
|
||||
GameSessionSnapshot snapshot = GameSessionSnapshot.builder(14)
|
||||
.elapsedSeconds(3f)
|
||||
.player(new GameSessionSnapshot.PlayerData(
|
||||
130f, 760f, 0f, 0f, true, true,
|
||||
0f, 0f, -1, 1))
|
||||
.build();
|
||||
|
||||
VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode(
|
||||
VersionedSessionCodec.encode(snapshot));
|
||||
|
||||
assertEquals(VersionedSessionCodec.Status.RESTORED, decoded.status());
|
||||
assertEquals(14, decoded.snapshot().levelNumber());
|
||||
}
|
||||
|
||||
private static GameSessionSnapshot sampleSnapshot() {
|
||||
return GameSessionSnapshot.builder(5)
|
||||
.elapsedSeconds(12.75f)
|
||||
|
||||
Reference in New Issue
Block a user