Добавить первую волну мира Physics

This commit is contained in:
2026-08-19 15:53:13 +03:00
parent f53e068cb1
commit 243da9185e
21 changed files with 647 additions and 28 deletions
@@ -24,7 +24,7 @@ public final class LevelPaginationTest {
GameView view = current.gameViewForAutomation();
view.setLanguageForAutomation(GameLanguage.EN);
assertEquals(0, view.levelPageIndexForAutomation());
assertEquals(3, view.levelPageCountForAutomation());
assertEquals(4, view.levelPageCountForAutomation());
assertTrue(view.visibleLocalizedTextForAutomation().contains("The Volunteer"));
assertFalse(view.visibleLocalizedTextForAutomation().contains("Steam Charge"));
});
@@ -43,7 +43,7 @@ public final class LevelPaginationTest {
assertTrue(visible.contains("Covered Path"));
assertFalse(visible.contains("Held Open"));
assertFalse(visible.contains("The Volunteer"));
assertTrue(visible.contains("Page 2 of 3"));
assertTrue(visible.contains("Page 2 of 4"));
});
tap(activity, 1_730f, 965f);
@@ -62,9 +62,25 @@ public final class LevelPaginationTest {
assertTrue(visible.contains("Minimum Cost"));
assertTrue(visible.contains("Foundation Relay"));
assertFalse(visible.contains("Covered Path"));
assertTrue(visible.contains("Page 3 of 3"));
assertTrue(visible.contains("Page 3 of 4"));
});
tap(activity, 1_730f, 965f);
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
List<String> visible = view.visibleLocalizedTextForAutomation();
assertEquals(3, view.levelPageIndexForAutomation());
assertTrue(visible.contains("Weight Class"));
assertTrue(visible.contains("Stack Order"));
assertTrue(visible.contains("Launch Window"));
assertTrue(visible.contains("Press Cycle"));
assertFalse(visible.contains("Foundation Relay"));
assertTrue(visible.contains("Page 4 of 4"));
});
tap(activity, 190f, 965f);
activity.onActivity(current ->
assertEquals(2, current.gameViewForAutomation().levelPageIndexForAutomation()));
tap(activity, 190f, 965f);
activity.onActivity(current ->
assertEquals(1, current.gameViewForAutomation().levelPageIndexForAutomation()));
@@ -0,0 +1,111 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
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 PhysicsWaveOnePlaythroughTest {
@Test public void weightClassOpensFromObjectMassThreeTimes() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
Driver driver = Driver.from(activity);
for (int run = 1; run <= 3; run++) {
assertAccepted(run, new LevelPlaytestBot().run(
driver, PhysicsWaveOneScenarios.levelThirtyOne(), 55_000L),
driver, 0, 0);
}
driver.restart();
GameView.AutomationSnapshot restarted = driver.snapshot();
assertEquals(31, restarted.levelNumber());
assertEquals(650f, restarted.firstPhysicalObjectX(), 0f);
assertEquals(780f, restarted.firstPhysicalObjectY(), 0f);
assertEquals(0, restarted.deaths());
assertEquals(0, restarted.corpseCount());
assertFalse(restarted.allDoorsOpen());
assertFalse(restarted.collectibleFound());
assertFalse(restarted.inputActive());
}
}
@Test public void stackOrderClimbsSupportedObjectsThreeTimes() {
runThree(PhysicsWaveOneScenarios.levelThirtyTwo(), 0, 0);
}
@Test public void launchWindowUsesOneSawVolunteerAndLiftThreeTimes() {
runThree(PhysicsWaveOneScenarios.levelThirtyThree(), 1, 1);
}
@Test public void pressCycleLeavesOneCrushedBodyThreeTimes() {
runThree(PhysicsWaveOneScenarios.levelThirtyFour(), 1, 1);
}
private static void runThree(LevelPlaytestBot.Scenario scenario,
int expectedDeaths, int expectedCorpses) {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
Driver driver = Driver.from(activity);
for (int run = 1; run <= 3; run++) {
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 55_000L);
assertAccepted(run, result, driver, expectedDeaths, expectedCorpses);
}
}
}
private static void assertAccepted(int run, LevelPlaytestBot.Result result, Driver driver,
int expectedDeaths, int expectedCorpses) {
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
assertEquals("run=" + run, expectedDeaths, result.deaths());
assertEquals("run=" + run, expectedCorpses, result.corpseCount());
assertTrue("run=" + run + "; collectible", driver.snapshot().collectibleFound());
}
private static final class Driver implements LevelPlaytestBot.Driver {
private final Instrumentation instrumentation;
private final GameView view;
private Driver(Instrumentation instrumentation, GameView view) {
this.instrumentation = instrumentation;
this.view = view;
}
static Driver from(ActivityScenario<MainActivity> activity) {
GameView[] target = new GameView[1];
activity.onActivity(current -> target[0] = current.gameViewForAutomation());
return new Driver(InstrumentationRegistry.getInstrumentation(), target[0]);
}
@Override public void startLevel(int oneBasedLevelNumber) {
instrumentation.runOnMainSync(() -> {
view.enableDeterministicAutomationClock();
view.startLevelForAutomation(oneBasedLevelNumber);
});
}
@Override public void setInput(boolean left, boolean right, boolean jump) {
instrumentation.runOnMainSync(() -> view.setAutomationInput(left, right, jump));
}
@Override public GameView.AutomationSnapshot snapshot() {
GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
instrumentation.runOnMainSync(() -> value[0] = view.automationSnapshot());
return value[0];
}
@Override public void waitForNextDecision(long millis) {
instrumentation.runOnMainSync(() ->
view.advanceDeterministicAutomation(Math.max(16L, millis)));
}
void restart() {
instrumentation.runOnMainSync(view::restartLevelForAutomation);
}
}
}
@@ -906,8 +906,9 @@ public final class GameView extends View implements Runnable {
object.vy += GRAVITY * dt;
object.grounded = false;
if (object.vx != 0f) movePhysicalObjectHorizontal(object, object.vx * dt);
float previousY = object.y;
object.y += object.vy * dt;
resolvePhysicalObjectVertical(object);
resolvePhysicalObjectVertical(object, previousY);
if (object.grounded) object.vx *= (float) Math.pow(0.04, dt);
if (Math.abs(object.vx) < 3f) object.vx = 0f;
}
@@ -949,7 +950,7 @@ public final class GameView extends View implements Runnable {
return Math.abs(object.x - before) + 0.01f >= Math.abs(amount);
}
private void resolvePhysicalObjectVertical(PhysicalObjectState object) {
private void resolvePhysicalObjectVertical(PhysicalObjectState object, float previousY) {
object.supportIndex = -1;
RectF current = physicalObjectRect(object);
for (int i = 0; i < platformStates.size(); i++) {
@@ -987,6 +988,7 @@ public final class GameView extends View implements Runnable {
object.vy = 0f;
current = physicalObjectRect(object);
}
PhysicalObjectStackResolver.settleFalling(object, previousY, physicalObjects);
}
private void updateCorpses(float dt) {
@@ -0,0 +1,35 @@
package com.projectsacrifice.game;
/** Resolves a falling authored object onto the first crossed object beneath it. */
final class PhysicalObjectStackResolver {
private static final float CONTACT_EPSILON = 1.5f;
private PhysicalObjectStackResolver() {}
static boolean settleFalling(PhysicalObjectState moving, float previousY,
Iterable<PhysicalObjectState> objects) {
if (moving == null || objects == null || moving.vy < 0f) return false;
float previousBottom = previousY + moving.height;
float currentBottom = moving.y + moving.height;
PhysicalObjectState nearest = null;
float nearestTop = Float.POSITIVE_INFINITY;
for (PhysicalObjectState support : objects) {
if (support == null || support == moving) continue;
if (moving.x >= support.x + support.width
|| moving.x + moving.width <= support.x) continue;
if (previousBottom > support.y + CONTACT_EPSILON) continue;
if (currentBottom + CONTACT_EPSILON < support.y) continue;
if (moving.y >= support.y) continue;
if (support.y < nearestTop) {
nearest = support;
nearestTop = support.y;
}
}
if (nearest == null) return false;
moving.y = nearest.y - moving.height;
moving.vy = 0f;
moving.grounded = true;
moving.supportIndex = -1;
return true;
}
}
@@ -0,0 +1,144 @@
package com.projectsacrifice.game;
/** Production-input routes for the first Physics-world authoring wave. */
public final class PhysicsWaveOneScenarios {
private PhysicsWaveOneScenarios() {}
public static LevelPlaytestBot.Scenario levelThirtyOne() {
return LevelPlaytestBot.Scenario.builder(31, "Level 31 - weight class")
.step("Observe the mass-three object", 1_000L, LevelPlaytestBot.Input.none(),
snapshot -> snapshot.physicalObjectCount() == 1
&& snapshot.firstPhysicalObjectMass() == 3
&& !snapshot.allDoorsOpen())
.step("Approach the counterweight", 2_500L, LevelPlaytestBot.Input.right(),
aliveAtMost(0, LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
.step("Push mass onto the plate", 3_000L, LevelPlaytestBot.Input.right(),
aliveAtMost(0, snapshot -> snapshot.firstPhysicalObjectX() >= 920f
&& snapshot.allDoorsOpen()))
.step("Release while mass holds the door", 1_000L,
LevelPlaytestBot.Input.none(),
aliveAtMost(0, GameView.AutomationSnapshot::allDoorsOpen))
.step("Take off over the weight", 1_500L,
LevelPlaytestBot.Input.rightJump(),
aliveAtMost(0, snapshot -> !snapshot.playerOnGround()
&& snapshot.playerY() <= 690f))
.step("Land beyond the plate", 2_500L, LevelPlaytestBot.Input.right(),
aliveAtMost(0, LevelPlaytestBot.groundedAliveAtOrBeyond(1_200f)))
.step("Collect the cache beyond the door", 3_000L,
LevelPlaytestBot.Input.right(),
aliveAtMost(0, snapshot -> snapshot.collectibleFound()
&& snapshot.playerX() >= 1_550f))
.step("Reach the relic", 4_000L, LevelPlaytestBot.Input.right(),
aliveAtMost(0, GameView.AutomationSnapshot::completed))
.build();
}
public static LevelPlaytestBot.Scenario levelThirtyTwo() {
return LevelPlaytestBot.Scenario.builder(32, "Level 32 - stack order")
.step("Observe the supported three-object stack", 1_000L,
LevelPlaytestBot.Input.none(),
snapshot -> snapshot.physicalObjectCount() == 3
&& snapshot.firstPhysicalObjectY() >= 799f
&& snapshot.firstPhysicalObjectY() <= 801f)
.step("Stage before the stack", 2_500L, LevelPlaytestBot.Input.right(),
aliveAtMost(0, LevelPlaytestBot.groundedAliveAtOrBeyond(480f)))
.step("Jump onto the lower object", 2_500L,
LevelPlaytestBot.Input.rightJump(),
aliveAtMost(0, snapshot -> snapshot.playerOnGround()
&& snapshot.playerX() >= 600f && snapshot.playerY() <= 700f))
.step("Climb to the top object", 3_000L,
LevelPlaytestBot.Input.rightJump(),
aliveAtMost(0, snapshot -> snapshot.playerOnGround()
&& snapshot.playerX() >= 720f && snapshot.playerY() <= 550f))
.step("Jump from the stack to the gallery", 3_000L,
LevelPlaytestBot.Input.rightJump(),
aliveAtMost(0, snapshot -> snapshot.playerOnGround()
&& snapshot.playerX() >= 1_030f && snapshot.playerY() <= 500f
&& snapshot.collectibleFound()))
.step("Cross the high gallery", 5_000L, LevelPlaytestBot.Input.right(),
aliveAtMost(0, GameView.AutomationSnapshot::completed))
.build();
}
public static LevelPlaytestBot.Scenario levelThirtyThree() {
return LevelPlaytestBot.Scenario.builder(33, "Level 33 - launch window")
.step("Read one saw launch", 4_000L, LevelPlaytestBot.Input.right(),
aliveAtMost(1, snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1))
.step("Observe the delivered body", 3_000L, LevelPlaytestBot.Input.none(),
aliveAtMost(1, snapshot -> snapshot.firstCorpseX() >= 900f))
.step("Wait for the lift runner", 2_000L, LevelPlaytestBot.Input.none(),
aliveAtMost(1, PhysicsWaveOneScenarios::respawned))
.step("Stage before the launch saw", 2_500L, LevelPlaytestBot.Input.right(),
aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(420f)))
.step("Take off before the launch saw", 1_500L,
LevelPlaytestBot.Input.rightJump(),
aliveAtMost(1, snapshot -> !snapshot.playerOnGround()
&& snapshot.playerY() <= 620f))
.step("Clear the launch saw", 2_000L, LevelPlaytestBot.Input.right(),
aliveAtMost(1, snapshot -> snapshot.playerX() >= 760f))
.step("Stage before the delivered body", 1_500L,
LevelPlaytestBot.Input.right(),
aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(820f)))
.step("Clear the delivered body", 3_000L,
LevelPlaytestBot.Input.rightJump(),
aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(1_200f)))
.step("Approach the lift", 1_500L, LevelPlaytestBot.Input.right(),
aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(1_240f)))
.step("Wait for the low lift phase", 4_000L, LevelPlaytestBot.Input.none(),
aliveAtMost(1, snapshot -> snapshot.firstMovingPlatformY() >= 770f))
.step("Board the lift", 2_500L, LevelPlaytestBot.Input.rightJump(),
aliveAtMost(1, snapshot -> snapshot.playerOnGround()
&& snapshot.playerOnMovingPlatform()))
.step("Ride to the launch window", 5_000L, LevelPlaytestBot.Input.none(),
aliveAtMost(1, snapshot -> snapshot.playerOnGround()
&& snapshot.playerOnMovingPlatform()
&& snapshot.firstMovingPlatformY() <= 430f))
.step("Jump onto the high gallery", 3_000L,
LevelPlaytestBot.Input.rightJump(),
aliveAtMost(1, snapshot -> snapshot.playerOnGround()
&& snapshot.playerX() >= 1_750f && snapshot.playerY() <= 400f))
.step("Collect the upper cache", 2_500L, LevelPlaytestBot.Input.right(),
aliveAtMost(1, snapshot -> snapshot.collectibleFound()))
.step("Reach the upper relic", 4_000L, LevelPlaytestBot.Input.right(),
aliveAtMost(1, GameView.AutomationSnapshot::completed))
.build();
}
public static LevelPlaytestBot.Scenario levelThirtyFour() {
return LevelPlaytestBot.Scenario.builder(34, "Level 34 - press cycle")
.step("Leave one volunteer in the press", 4_000L,
LevelPlaytestBot.Input.right(),
aliveAtMost(1, snapshot -> snapshot.deaths() == 1
&& snapshot.corpseCount() == 1
&& snapshot.firstCorpseCrushed()
&& snapshot.firstCorpseActivatesPressure()))
.step("Wait for the cycle runner", 2_000L, LevelPlaytestBot.Input.none(),
aliveAtMost(1, PhysicsWaveOneScenarios::respawned))
.step("Approach the occupied press", 2_500L,
LevelPlaytestBot.Input.right(),
aliveAtMost(1, LevelPlaytestBot.groundedAliveAtOrBeyond(500f)))
.step("Observe the press close", 5_000L, LevelPlaytestBot.Input.none(),
aliveAtMost(1, GameView.AutomationSnapshot::firstHazardActive))
.step("Wait for the full rest", 5_000L, LevelPlaytestBot.Input.none(),
aliveAtMost(1, snapshot -> !snapshot.firstHazardActive()))
.step("Jump through the resting press", 2_500L,
LevelPlaytestBot.Input.rightJump(),
aliveAtMost(1, snapshot -> snapshot.playerX() >= 930f))
.step("Collect the press cache", 3_000L, LevelPlaytestBot.Input.right(),
aliveAtMost(1, snapshot -> snapshot.collectibleFound()))
.step("Reach the relic", 5_000L, LevelPlaytestBot.Input.right(),
aliveAtMost(1, GameView.AutomationSnapshot::completed))
.build();
}
private static LevelPlaytestBot.SnapshotCondition aliveAtMost(
int deaths, LevelPlaytestBot.SnapshotCondition condition) {
return LevelPlaytestBot.whileDeathsAtMost(deaths,
snapshot -> snapshot.playerAlive() && condition.reached(snapshot));
}
private static boolean respawned(GameView.AutomationSnapshot snapshot) {
return snapshot.playerAlive() && snapshot.playerX() < 250f;
}
}
@@ -13,7 +13,7 @@ public final class FoundationsFinaleContentTest {
@Test
public void finalFoundationsWaveMatchesTheApprovedMatrixAndClosesTheWorld() {
List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(30, levels.size());
assertTrue(levels.size() >= 30);
LevelDefinition keys = levels.get(26);
assertIdentity(keys, 27, "foundations.two-keys", "Two Keys", 2, 65);
@@ -15,7 +15,7 @@ public final class LevelCatalogTest {
@Test
public void productionCatalogContainsMechanicsAndChallengeTeachingLevels() {
List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(30, levels.size());
assertEquals(34, levels.size());
Set<String> titles = new HashSet<>();
for (int i = 0; i < levels.size(); i++) {
LevelDefinition level = levels.get(i);
@@ -0,0 +1,47 @@
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;
import java.util.Arrays;
public final class PhysicalObjectStackResolverTest {
@Test
public void fallingObjectSettlesOnTheHighestCrossedObject() {
PhysicalObjectState lower = object(650f, 800f, 160f, 80f);
PhysicalObjectState middle = object(690f, 721f, 140f, 80f)
.velocity(0f, 60f);
PhysicalObjectState distant = object(1_200f, 700f, 100f, 100f);
boolean settled = PhysicalObjectStackResolver.settleFalling(
middle, 720f, Arrays.asList(lower, middle, distant));
assertTrue(settled);
assertEquals(720f, middle.weightedY(), 0f);
assertEquals(0f, middle.velocityY(), 0f);
assertTrue(middle.grounded());
}
@Test
public void risingOrNonCrossingObjectIsNotTeleportedOntoAStack() {
PhysicalObjectState lower = object(650f, 800f, 160f, 80f);
PhysicalObjectState rising = object(690f, 700f, 120f, 80f)
.velocity(0f, -40f);
PhysicalObjectState beside = object(300f, 721f, 100f, 80f)
.velocity(0f, 60f);
assertFalse(PhysicalObjectStackResolver.settleFalling(
rising, 704f, Arrays.asList(lower, rising)));
assertFalse(PhysicalObjectStackResolver.settleFalling(
beside, 720f, Arrays.asList(lower, beside)));
}
private static PhysicalObjectState object(float x, float y, float width, float height) {
return new PhysicalObjectState(new LevelDefinition.PhysicalObject(
LevelDefinition.PhysicalObjectType.BOX,
new LevelDefinition.Box(x, y, width, height), 1));
}
}
@@ -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.assertNotNull;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
import java.util.List;
public final class PhysicsWaveOneContentTest {
@Test
public void firstPhysicsWaveMatchesTheExecutableMatrix() {
List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(34, levels.size());
LevelDefinition mass = levels.get(30);
assertIdentity(mass, 31, "physics.mass-basics", "Weight Class", 0, 45);
assertFalse(mass.objects.isEmpty());
assertTrue(mass.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
assertFalse(mass.doors.isEmpty());
LevelDefinition stack = levels.get(31);
assertIdentity(stack, 32, "physics.stack-order", "Stack Order", 0, 50);
assertTrue(stack.objects.size() >= 3);
assertTrue("The authored staircase must contain an object supported by another",
hasAuthoredObjectStack(stack));
LevelDefinition launch = levels.get(32);
assertIdentity(launch, 33, "physics.launch-window", "Launch Window", 1, 55);
assertTrue(launch.containsMechanic(LevelDefinition.HazardType.SAW));
assertTrue(launch.hasMovingPlatform());
LevelDefinition crusher = levels.get(33);
assertIdentity(crusher, 34, "physics.crusher-rhythm", "Press Cycle", 1, 55);
assertTrue(crusher.containsMechanic(LevelDefinition.HazardType.CRUSHER));
assertTrue(crusher.containsMechanic(LevelDefinition.SwitchType.PRESSURE));
}
private static boolean hasAuthoredObjectStack(LevelDefinition level) {
for (LevelDefinition.PhysicalObject upper : level.objects) {
for (LevelDefinition.PhysicalObject lower : level.objects) {
if (upper == lower) continue;
boolean horizontalOverlap = upper.box.x < lower.box.right()
&& upper.box.right() > lower.box.x;
if (horizontalOverlap
&& Math.abs(upper.box.bottom() - lower.box.y) <= 0.01f) return true;
}
}
return false;
}
private static void assertIdentity(LevelDefinition level, int number, String id, String title,
int targetDeaths, int targetSeconds) {
assertEquals(number, level.number);
assertEquals(id, level.levelId);
assertEquals("physics", level.worldId);
assertEquals(title, level.title);
assertEquals(targetDeaths, level.targetDeaths);
assertEquals(targetSeconds, level.targetSeconds);
assertNotNull(level.collectible);
}
}