Добавить production-каталог опасностей

This commit is contained in:
2026-08-19 05:30:56 +03:00
parent cc7f487cf7
commit dc2ebcbf8c
33 changed files with 1160 additions and 37 deletions
@@ -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();
});
}
}
@@ -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)