Добавить общую систему весовых объектов
This commit is contained in:
@@ -37,6 +37,7 @@ public final class LevelPaginationTest {
|
||||
assertEquals(1, view.levelPageIndexForAutomation());
|
||||
assertTrue(visible.contains("Steam Charge"));
|
||||
assertTrue(visible.contains("Ashen Counterweight"));
|
||||
assertTrue(visible.contains("The Whole Weight"));
|
||||
assertFalse(visible.contains("The Volunteer"));
|
||||
assertTrue(visible.contains("Page 2 of 2"));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class WeightedObjectIntegrationTest {
|
||||
@Test
|
||||
public void productionInputPushesSharedMassOntoPlateAndSaveCapturesEveryObject() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.enableDeterministicAutomationClock();
|
||||
view.startLevelForAutomation(17);
|
||||
|
||||
GameView.AutomationSnapshot initial = view.automationSnapshot();
|
||||
assertEquals(5, initial.physicalObjectCount());
|
||||
assertEquals("COUNTERWEIGHT", initial.firstPhysicalObjectType());
|
||||
assertEquals(3, initial.firstPhysicalObjectMass());
|
||||
assertFalse(initial.allDoorsOpen());
|
||||
|
||||
view.setAutomationInput(false, true, false);
|
||||
view.advanceDeterministicAutomation(3_000L);
|
||||
GameView.AutomationSnapshot pushed = view.automationSnapshot();
|
||||
assertTrue(pushed.firstPhysicalObjectX() >= 920f);
|
||||
assertTrue(pushed.allDoorsOpen());
|
||||
|
||||
GameSessionSnapshot saved = view.sessionSnapshotForAutomation();
|
||||
assertEquals(5, saved.objects().size());
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.COUNTERWEIGHT,
|
||||
saved.objects().get(0).type());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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 WeightedObjectPlaythroughTest {
|
||||
@Test
|
||||
public void allFiveObjectTypesOpenTheMassNineGateThreeTimes() {
|
||||
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, WeightedObjectScenarios.levelSeventeen(), 50_000L);
|
||||
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
|
||||
assertEquals("weighted route is deathless", 0, result.deaths());
|
||||
assertEquals("weighted route does not create bodies", 0, result.corpseCount());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.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[] snapshot = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> snapshot[0] = view.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("Weighted-object bot interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Production-input route for the weighted-object teaching puzzle. */
|
||||
final class WeightedObjectScenarios {
|
||||
private WeightedObjectScenarios() {}
|
||||
|
||||
static LevelPlaytestBot.Scenario levelSeventeen() {
|
||||
return LevelPlaytestBot.Scenario.builder(17, "Level 17 - shared mass gate")
|
||||
.step("Observe all authored weight types", 1_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.physicalObjectCount() == 5
|
||||
&& snapshot.firstPhysicalObjectMass() == 3
|
||||
&& "COUNTERWEIGHT".equals(snapshot.firstPhysicalObjectType())
|
||||
&& !snapshot.allDoorsOpen())
|
||||
.step("Approach the counterweight", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(520f)))
|
||||
.step("Push the missing mass onto the plate", 3_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.firstPhysicalObjectX() >= 920f
|
||||
&& snapshot.allDoorsOpen()))
|
||||
.step("Release input while object mass holds the gate", 1_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.allDoorsOpen()
|
||||
&& snapshot.firstPhysicalObjectX() >= 920f)
|
||||
.step("Take off over the counterweight", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 700f))
|
||||
.step("Land beyond the counterweight", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 1_080f))
|
||||
.step("Jump over the weighted row", 2_500L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 1_650f))
|
||||
.step("Pass the mass-nine door and reach the relic", 5_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(0,
|
||||
GameView.AutomationSnapshot::completed))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ public final class CorpseManager {
|
||||
CRUSHED
|
||||
}
|
||||
|
||||
public static final class Body {
|
||||
public static final class Body implements IWeightedObject {
|
||||
final long id;
|
||||
final String deathType;
|
||||
float x;
|
||||
@@ -54,6 +54,11 @@ public final class CorpseManager {
|
||||
public boolean activatesPressure() { return !burned; }
|
||||
public boolean grounded() { return grounded; }
|
||||
public int supportIndex() { return supportIndex; }
|
||||
@Override public float weightedX() { return x; }
|
||||
@Override public float weightedY() { return y; }
|
||||
@Override public float weightedWidth() { return width; }
|
||||
@Override public float weightedHeight() { return height; }
|
||||
@Override public int pressureMass() { return 1; }
|
||||
|
||||
public Body position(float x, float y) {
|
||||
this.x = x;
|
||||
|
||||
@@ -92,6 +92,41 @@ public final class GameSessionSnapshot {
|
||||
public String deathType() { return deathType; }
|
||||
}
|
||||
|
||||
public static final class ObjectData {
|
||||
private final LevelDefinition.PhysicalObjectType type;
|
||||
private final float x, y, width, height, velocityX, velocityY;
|
||||
private final int mass;
|
||||
private final boolean grounded;
|
||||
private final int supportIndex;
|
||||
|
||||
public ObjectData(LevelDefinition.PhysicalObjectType type,
|
||||
float x, float y, float width, float height,
|
||||
float velocityX, float velocityY, int mass,
|
||||
boolean grounded, int supportIndex) {
|
||||
this.type = type;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.velocityX = velocityX;
|
||||
this.velocityY = velocityY;
|
||||
this.mass = mass;
|
||||
this.grounded = grounded;
|
||||
this.supportIndex = supportIndex;
|
||||
}
|
||||
|
||||
public LevelDefinition.PhysicalObjectType type() { return type; }
|
||||
public float x() { return x; }
|
||||
public float y() { return y; }
|
||||
public float width() { return width; }
|
||||
public float height() { return height; }
|
||||
public float velocityX() { return velocityX; }
|
||||
public float velocityY() { return velocityY; }
|
||||
public int mass() { return mass; }
|
||||
public boolean grounded() { return grounded; }
|
||||
public int supportIndex() { return supportIndex; }
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
private int saveVersion = VersionedSessionCodec.CURRENT_VERSION;
|
||||
private final int levelNumber;
|
||||
@@ -101,6 +136,7 @@ public final class GameSessionSnapshot {
|
||||
private PlayerData player = new PlayerData(
|
||||
0f, 0f, 0f, 0f, true, false, 0f, 0f, -1, 1);
|
||||
private final ArrayList<BodyData> bodies = new ArrayList<>();
|
||||
private final ArrayList<ObjectData> objects = new ArrayList<>();
|
||||
private final ArrayList<Float> platformMotionTimes = new ArrayList<>();
|
||||
private final LinkedHashMap<String, Boolean> toggleSignals = new LinkedHashMap<>();
|
||||
private final LinkedHashMap<String, Float> timedSignals = new LinkedHashMap<>();
|
||||
@@ -117,6 +153,7 @@ public final class GameSessionSnapshot {
|
||||
public Builder respawnSeconds(float value) { respawnSeconds = value; return this; }
|
||||
public Builder player(PlayerData value) { player = value; return this; }
|
||||
public Builder addBody(BodyData value) { bodies.add(value); return this; }
|
||||
public Builder addObject(ObjectData value) { objects.add(value); return this; }
|
||||
public Builder platformMotionTimes(List<Float> values) {
|
||||
platformMotionTimes.clear();
|
||||
if (values != null) platformMotionTimes.addAll(values);
|
||||
@@ -144,6 +181,7 @@ public final class GameSessionSnapshot {
|
||||
private final float respawnSeconds;
|
||||
private final PlayerData player;
|
||||
private final List<BodyData> bodies;
|
||||
private final List<ObjectData> objects;
|
||||
private final List<Float> platformMotionTimes;
|
||||
private final Map<String, Boolean> toggleSignals;
|
||||
private final Map<String, Float> timedSignals;
|
||||
@@ -157,6 +195,7 @@ public final class GameSessionSnapshot {
|
||||
respawnSeconds = builder.respawnSeconds;
|
||||
player = builder.player;
|
||||
bodies = Collections.unmodifiableList(new ArrayList<>(builder.bodies));
|
||||
objects = Collections.unmodifiableList(new ArrayList<>(builder.objects));
|
||||
platformMotionTimes = Collections.unmodifiableList(
|
||||
new ArrayList<>(builder.platformMotionTimes));
|
||||
toggleSignals = Collections.unmodifiableMap(
|
||||
@@ -174,6 +213,7 @@ public final class GameSessionSnapshot {
|
||||
public float respawnSeconds() { return respawnSeconds; }
|
||||
public PlayerData player() { return player; }
|
||||
public List<BodyData> bodies() { return bodies; }
|
||||
public List<ObjectData> objects() { return objects; }
|
||||
public List<Float> platformMotionTimes() { return platformMotionTimes; }
|
||||
public Map<String, Boolean> toggleSignals() { return toggleSignals; }
|
||||
public Map<String, Float> timedSignals() { return timedSignals; }
|
||||
|
||||
@@ -80,6 +80,11 @@ public final class GameView extends View implements Runnable {
|
||||
private final boolean firstCorpseActivatesPressure;
|
||||
private final float firstCorpseWidth;
|
||||
private final float firstCorpseHeight;
|
||||
private int physicalObjectCount;
|
||||
private float firstPhysicalObjectX = Float.NaN;
|
||||
private float firstPhysicalObjectY = Float.NaN;
|
||||
private int firstPhysicalObjectMass;
|
||||
private String firstPhysicalObjectType = "";
|
||||
|
||||
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
|
||||
boolean visiblyPaused, boolean inputActive, int levelNumber,
|
||||
@@ -224,6 +229,40 @@ public final class GameView extends View implements Runnable {
|
||||
this.firstCorpseHeight = firstCorpseHeight;
|
||||
}
|
||||
|
||||
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,
|
||||
boolean firstCorpseBurned, boolean firstCorpseCrushed,
|
||||
boolean firstCorpseActivatesPressure,
|
||||
float firstCorpseWidth, float firstCorpseHeight,
|
||||
int physicalObjectCount, float firstPhysicalObjectX,
|
||||
float firstPhysicalObjectY, int firstPhysicalObjectMass,
|
||||
String firstPhysicalObjectType) {
|
||||
this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber,
|
||||
elapsedMilliseconds, playerX, playerY, deaths, corpseCount,
|
||||
firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform,
|
||||
firstMovingPlatformPowered, firstMovingPlatformMovingRight,
|
||||
firstMovingPlatformMovingDown, firstCorpseX, playerOnGround,
|
||||
playerVelocityX, playerVelocityY, firstHazardActive, firstHazardX,
|
||||
firstHazardY, firstCorpseBurned, firstCorpseCrushed,
|
||||
firstCorpseActivatesPressure, firstCorpseWidth, firstCorpseHeight);
|
||||
this.physicalObjectCount = physicalObjectCount;
|
||||
this.firstPhysicalObjectX = firstPhysicalObjectX;
|
||||
this.firstPhysicalObjectY = firstPhysicalObjectY;
|
||||
this.firstPhysicalObjectMass = firstPhysicalObjectMass;
|
||||
this.firstPhysicalObjectType = firstPhysicalObjectType == null
|
||||
? "" : firstPhysicalObjectType;
|
||||
}
|
||||
|
||||
public boolean completed() { return completed; }
|
||||
public boolean playerAlive() { return playerAlive; }
|
||||
public boolean allDoorsOpen() { return allDoorsOpen; }
|
||||
@@ -255,9 +294,14 @@ public final class GameView extends View implements Runnable {
|
||||
}
|
||||
public float firstCorpseWidth() { return firstCorpseWidth; }
|
||||
public float firstCorpseHeight() { return firstCorpseHeight; }
|
||||
public int physicalObjectCount() { return physicalObjectCount; }
|
||||
public float firstPhysicalObjectX() { return firstPhysicalObjectX; }
|
||||
public float firstPhysicalObjectY() { return firstPhysicalObjectY; }
|
||||
public int firstPhysicalObjectMass() { return firstPhysicalObjectMass; }
|
||||
public String firstPhysicalObjectType() { return firstPhysicalObjectType; }
|
||||
}
|
||||
|
||||
private static final class Player {
|
||||
private static final class Player implements IWeightedObject {
|
||||
float x, y, vx, vy;
|
||||
boolean alive;
|
||||
boolean onGround;
|
||||
@@ -265,6 +309,13 @@ public final class GameView extends View implements Runnable {
|
||||
float jumpBuffer;
|
||||
int supportIndex = -1;
|
||||
int facing = 1;
|
||||
|
||||
@Override public float weightedX() { return x; }
|
||||
@Override public float weightedY() { return y; }
|
||||
@Override public float weightedWidth() { return PLAYER_WIDTH; }
|
||||
@Override public float weightedHeight() { return PLAYER_HEIGHT; }
|
||||
@Override public int pressureMass() { return 1; }
|
||||
@Override public boolean activatesPressure() { return alive; }
|
||||
}
|
||||
|
||||
private static final class PlatformState {
|
||||
@@ -302,6 +353,7 @@ public final class GameView extends View implements Runnable {
|
||||
new CharacterStateMachine(PHYSICS);
|
||||
private final CorpseManager corpseManager = new CorpseManager(MAX_CORPSES);
|
||||
private final List<Body> corpses = corpseManager.bodies();
|
||||
private final ArrayList<PhysicalObjectState> physicalObjects = new ArrayList<>();
|
||||
private final ArrayList<PlatformState> platformStates = new ArrayList<>();
|
||||
private final ArrayList<HazardRuntime> hazardRuntimes = new ArrayList<>();
|
||||
private final Map<String, Boolean> previousSwitchContact = new HashMap<>();
|
||||
@@ -470,6 +522,7 @@ public final class GameView extends View implements Runnable {
|
||||
pointers.clear();
|
||||
deriveControls();
|
||||
corpseManager.reset();
|
||||
physicalObjects.clear();
|
||||
platformStates.clear();
|
||||
hazardRuntimes.clear();
|
||||
previousSwitchContact.clear();
|
||||
@@ -478,6 +531,9 @@ public final class GameView extends View implements Runnable {
|
||||
configureAccessibilitySignals();
|
||||
invalidateHudTextCache();
|
||||
for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item));
|
||||
for (LevelDefinition.PhysicalObject item : level.objects) {
|
||||
physicalObjects.add(new PhysicalObjectState(item));
|
||||
}
|
||||
for (LevelDefinition.Hazard item : level.hazards) {
|
||||
hazardRuntimes.add(new HazardRuntime(item));
|
||||
}
|
||||
@@ -516,6 +572,7 @@ public final class GameView extends View implements Runnable {
|
||||
int substeps = 2;
|
||||
float step = dt / substeps;
|
||||
for (int i = 0; i < substeps; i++) {
|
||||
updatePhysicalObjects(step);
|
||||
updateCorpses(step);
|
||||
if (player.alive) updatePlayer(step);
|
||||
}
|
||||
@@ -633,10 +690,12 @@ public final class GameView extends View implements Runnable {
|
||||
|
||||
private boolean switchContact(LevelDefinition.Switch item) {
|
||||
int mass = 0;
|
||||
if (player.alive && intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, item.box)) mass++;
|
||||
mass += PressureMassResolver.contribution(item.box, player);
|
||||
for (Body corpse : corpses) {
|
||||
if (!corpse.activatesPressure()) continue;
|
||||
if (intersects(corpse.x, corpse.y, corpse.width, corpse.height, item.box)) mass++;
|
||||
mass += PressureMassResolver.contribution(item.box, corpse);
|
||||
}
|
||||
for (PhysicalObjectState object : physicalObjects) {
|
||||
mass += PressureMassResolver.contribution(item.box, object);
|
||||
}
|
||||
return mass >= item.requiredMass;
|
||||
}
|
||||
@@ -675,6 +734,13 @@ public final class GameView extends View implements Runnable {
|
||||
corpse.y += support.y - support.previousY;
|
||||
}
|
||||
}
|
||||
for (PhysicalObjectState object : physicalObjects) {
|
||||
if (object.supportIndex >= 0 && object.supportIndex < platformStates.size()) {
|
||||
PlatformState support = platformStates.get(object.supportIndex);
|
||||
object.x += support.x - support.previousX;
|
||||
object.y += support.y - support.previousY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updatePlayer(float dt) {
|
||||
@@ -712,6 +778,19 @@ public final class GameView extends View implements Runnable {
|
||||
if (amount == 0f) return;
|
||||
player.x += amount;
|
||||
RectF current = playerRect();
|
||||
for (PhysicalObjectState object : physicalObjects) {
|
||||
RectF solid = physicalObjectRect(object);
|
||||
if (!RectF.intersects(current, solid)) continue;
|
||||
boolean moved = movePhysicalObjectHorizontal(object, amount);
|
||||
solid = physicalObjectRect(object);
|
||||
current = playerRect();
|
||||
if (!moved || RectF.intersects(current, solid)) {
|
||||
if (amount > 0f) player.x = solid.left - PLAYER_WIDTH;
|
||||
else player.x = solid.right;
|
||||
player.vx = 0f;
|
||||
current = playerRect();
|
||||
}
|
||||
}
|
||||
for (RectF solid : solidRects(true)) {
|
||||
if (!RectF.intersects(current, solid)) continue;
|
||||
if (amount > 0f) player.x = solid.left - PLAYER_WIDTH;
|
||||
@@ -765,6 +844,107 @@ public final class GameView extends View implements Runnable {
|
||||
}
|
||||
current = playerRect();
|
||||
}
|
||||
for (PhysicalObjectState object : physicalObjects) {
|
||||
RectF solid = physicalObjectRect(object);
|
||||
if (!RectF.intersects(current, solid)) continue;
|
||||
if (amount >= 0f) {
|
||||
player.y = solid.top - PLAYER_HEIGHT;
|
||||
player.vy = 0f;
|
||||
player.onGround = true;
|
||||
} else {
|
||||
player.y = solid.bottom;
|
||||
player.vy = 0f;
|
||||
}
|
||||
current = playerRect();
|
||||
}
|
||||
}
|
||||
|
||||
private void updatePhysicalObjects(float dt) {
|
||||
for (PhysicalObjectState object : physicalObjects) {
|
||||
object.vy += GRAVITY * dt;
|
||||
object.grounded = false;
|
||||
if (object.vx != 0f) movePhysicalObjectHorizontal(object, object.vx * dt);
|
||||
object.y += object.vy * dt;
|
||||
resolvePhysicalObjectVertical(object);
|
||||
if (object.grounded) object.vx *= (float) Math.pow(0.04, dt);
|
||||
if (Math.abs(object.vx) < 3f) object.vx = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean movePhysicalObjectHorizontal(PhysicalObjectState object, float amount) {
|
||||
if (amount == 0f) return true;
|
||||
float before = object.x;
|
||||
object.x += amount;
|
||||
RectF current = physicalObjectRect(object);
|
||||
for (PlatformState platform : platformStates) {
|
||||
RectF solid = platform.rect();
|
||||
if (!RectF.intersects(current, solid)) continue;
|
||||
object.x = amount > 0f ? solid.left - object.width : solid.right;
|
||||
current = physicalObjectRect(object);
|
||||
}
|
||||
for (LevelDefinition.Door door : level.doors) {
|
||||
if (doorOpen(door)) continue;
|
||||
RectF solid = rect(door.box);
|
||||
if (!RectF.intersects(current, solid)) continue;
|
||||
object.x = amount > 0f ? solid.left - object.width : solid.right;
|
||||
current = physicalObjectRect(object);
|
||||
}
|
||||
for (Body corpse : corpses) {
|
||||
RectF solid = corpseRect(corpse);
|
||||
if (!RectF.intersects(current, solid)) continue;
|
||||
object.x = amount > 0f ? solid.left - object.width : solid.right;
|
||||
current = physicalObjectRect(object);
|
||||
}
|
||||
for (PhysicalObjectState other : physicalObjects) {
|
||||
if (other == object) continue;
|
||||
RectF solid = physicalObjectRect(other);
|
||||
if (!RectF.intersects(current, solid)) continue;
|
||||
object.x = amount > 0f ? solid.left - object.width : solid.right;
|
||||
current = physicalObjectRect(object);
|
||||
}
|
||||
object.x = GameMath.clamp(object.x, 0f, level.worldWidth - object.width);
|
||||
if (Math.abs(object.x - before) + 0.01f < Math.abs(amount)) object.vx = 0f;
|
||||
return Math.abs(object.x - before) + 0.01f >= Math.abs(amount);
|
||||
}
|
||||
|
||||
private void resolvePhysicalObjectVertical(PhysicalObjectState object) {
|
||||
object.supportIndex = -1;
|
||||
RectF current = physicalObjectRect(object);
|
||||
for (int i = 0; i < platformStates.size(); i++) {
|
||||
RectF solid = platformStates.get(i).rect();
|
||||
if (!RectF.intersects(current, solid)) continue;
|
||||
if (object.vy >= 0f) {
|
||||
object.y = solid.top - object.height;
|
||||
object.vy = 0f;
|
||||
object.grounded = true;
|
||||
object.supportIndex = i;
|
||||
} else {
|
||||
object.y = solid.bottom;
|
||||
object.vy = 0f;
|
||||
}
|
||||
current = physicalObjectRect(object);
|
||||
}
|
||||
for (LevelDefinition.Door door : level.doors) {
|
||||
if (doorOpen(door)) continue;
|
||||
RectF solid = rect(door.box);
|
||||
if (!RectF.intersects(current, solid)) continue;
|
||||
if (object.vy >= 0f) {
|
||||
object.y = solid.top - object.height;
|
||||
object.grounded = true;
|
||||
} else object.y = solid.bottom;
|
||||
object.vy = 0f;
|
||||
current = physicalObjectRect(object);
|
||||
}
|
||||
for (Body corpse : corpses) {
|
||||
RectF solid = corpseRect(corpse);
|
||||
if (!RectF.intersects(current, solid)) continue;
|
||||
if (object.vy >= 0f) {
|
||||
object.y = solid.top - object.height;
|
||||
object.grounded = true;
|
||||
} else object.y = solid.bottom;
|
||||
object.vy = 0f;
|
||||
current = physicalObjectRect(object);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateCorpses(float dt) {
|
||||
@@ -848,6 +1028,9 @@ public final class GameView extends View implements Runnable {
|
||||
for (PlatformState platform : platformStates) result.add(platform.rect());
|
||||
for (LevelDefinition.Door door : level.doors) if (!doorOpen(door)) result.add(rect(door.box));
|
||||
if (includeCorpses) for (Body corpse : corpses) result.add(corpseRect(corpse));
|
||||
for (PhysicalObjectState object : physicalObjects) {
|
||||
result.add(physicalObjectRect(object));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -978,6 +1161,11 @@ public final class GameView extends View implements Runnable {
|
||||
corpse.x + corpse.width, corpse.y + corpse.height);
|
||||
}
|
||||
|
||||
private static RectF physicalObjectRect(PhysicalObjectState object) {
|
||||
return new RectF(object.x, object.y,
|
||||
object.x + object.width, object.y + object.height);
|
||||
}
|
||||
|
||||
private static RectF rect(LevelDefinition.Box box) {
|
||||
return new RectF(box.x, box.y, box.right(), box.bottom());
|
||||
}
|
||||
@@ -1016,13 +1204,14 @@ public final class GameView extends View implements Runnable {
|
||||
private void recordPerformanceComplexity() {
|
||||
if (level == null) return;
|
||||
int bodies = corpses.size();
|
||||
int solids = platformStates.size() + level.doors.size() + bodies;
|
||||
int actors = 1 + bodies;
|
||||
int objects = physicalObjects.size();
|
||||
int solids = platformStates.size() + level.doors.size() + bodies + objects;
|
||||
int actors = 1 + bodies + objects;
|
||||
int collisionChecks = solids * actors * 2
|
||||
+ level.hazards.size() + level.switches.size() * actors;
|
||||
int drawPrimitives = 180 + platformStates.size() * 8
|
||||
+ level.switches.size() * 9 + level.hazards.size() * 20
|
||||
+ level.doors.size() * 12 + bodies * 28;
|
||||
+ level.doors.size() * 12 + bodies * 28 + objects * 8;
|
||||
Runtime runtime = Runtime.getRuntime();
|
||||
long usedMemory = runtime.totalMemory() - runtime.freeMemory();
|
||||
performanceMonitor.recordComplexity(bodies, collisionChecks,
|
||||
@@ -1039,6 +1228,7 @@ public final class GameView extends View implements Runnable {
|
||||
drawHazards(canvas);
|
||||
drawDoors(canvas);
|
||||
drawGoal(canvas);
|
||||
for (PhysicalObjectState object : physicalObjects) drawPhysicalObject(canvas, object);
|
||||
for (Body corpse : corpses) drawCorpse(canvas, corpse);
|
||||
VolunteerRig.Skin skin = VolunteerRig.Skin.forLevel(level.number);
|
||||
if (player.alive) {
|
||||
@@ -1398,6 +1588,50 @@ public final class GameView extends View implements Runnable {
|
||||
}
|
||||
}
|
||||
|
||||
private void drawPhysicalObject(Canvas canvas, PhysicalObjectState object) {
|
||||
RectF box = physicalObjectRect(object);
|
||||
boolean contrast = accessibilitySettings.highContrastEnabled();
|
||||
switch (object.type) {
|
||||
case BOX:
|
||||
paint.setColor(contrast ? Color.WHITE : Color.rgb(151, 103, 55));
|
||||
canvas.drawRoundRect(box, 8f, 8f, paint);
|
||||
stroke.setColor(contrast ? Color.BLACK : Color.rgb(225, 176, 91));
|
||||
stroke.setStrokeWidth(5f);
|
||||
canvas.drawLine(box.left + 8f, box.top + 8f,
|
||||
box.right - 8f, box.bottom - 8f, stroke);
|
||||
canvas.drawLine(box.right - 8f, box.top + 8f,
|
||||
box.left + 8f, box.bottom - 8f, stroke);
|
||||
break;
|
||||
case ROCK:
|
||||
paint.setColor(contrast ? Color.WHITE : Color.rgb(109, 112, 123));
|
||||
canvas.drawOval(box, paint);
|
||||
break;
|
||||
case BALL:
|
||||
paint.setColor(contrast ? Color.WHITE : Color.rgb(202, 126, 56));
|
||||
canvas.drawCircle(box.centerX(), box.centerY(),
|
||||
Math.min(box.width(), box.height()) * 0.5f, paint);
|
||||
break;
|
||||
case ICE_BLOCK:
|
||||
paint.setColor(contrast ? Color.WHITE : Color.rgb(106, 209, 235));
|
||||
canvas.drawRoundRect(box, 12f, 12f, paint);
|
||||
stroke.setColor(contrast ? Color.BLACK : Color.rgb(221, 252, 255));
|
||||
stroke.setStrokeWidth(5f);
|
||||
canvas.drawLine(box.left + 10f, box.bottom - 12f,
|
||||
box.right - 12f, box.top + 10f, stroke);
|
||||
break;
|
||||
case COUNTERWEIGHT:
|
||||
paint.setColor(contrast ? Color.WHITE : Color.rgb(89, 79, 66));
|
||||
canvas.drawRoundRect(box, 14f, 14f, paint);
|
||||
stroke.setColor(contrast ? Color.BLACK : Color.rgb(237, 190, 77));
|
||||
stroke.setStrokeWidth(7f);
|
||||
canvas.drawLine(box.centerX(), box.top + 10f,
|
||||
box.centerX(), box.bottom - 10f, stroke);
|
||||
break;
|
||||
}
|
||||
drawWorldLabel(canvas, object.type.name() + " W" + object.mass,
|
||||
box.centerX(), box.top - 16f);
|
||||
}
|
||||
|
||||
private void drawHud(Canvas canvas) {
|
||||
ensureHudTextCache();
|
||||
paint.setColor(Color.argb(178, 15, 20, 34));
|
||||
@@ -2575,6 +2809,12 @@ public final class GameView extends View implements Runnable {
|
||||
corpse.supportIndex,
|
||||
corpse.deathType()));
|
||||
}
|
||||
for (PhysicalObjectState object : physicalObjects) {
|
||||
builder.addObject(new GameSessionSnapshot.ObjectData(
|
||||
object.type, object.x, object.y, object.width, object.height,
|
||||
object.vx, object.vy, object.mass, object.grounded,
|
||||
object.supportIndex));
|
||||
}
|
||||
ArrayList<Float> motionTimes = new ArrayList<>(platformStates.size());
|
||||
for (PlatformState state : platformStates) motionTimes.add(state.motionTime);
|
||||
builder.platformMotionTimes(motionTimes);
|
||||
@@ -2597,6 +2837,8 @@ public final class GameView extends View implements Runnable {
|
||||
LevelDefinition definition = LevelCatalog.get(index);
|
||||
if (snapshot.platformMotionTimes().size() != definition.platforms.size()
|
||||
|| snapshot.bodies().size() > MAX_CORPSES) return false;
|
||||
if (!snapshot.objects().isEmpty()
|
||||
&& snapshot.objects().size() != definition.objects.size()) return false;
|
||||
if (!validSupport(snapshot.player().supportIndex(), definition.platforms.size())) {
|
||||
return false;
|
||||
}
|
||||
@@ -2608,6 +2850,13 @@ public final class GameView extends View implements Runnable {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < snapshot.objects().size(); i++) {
|
||||
GameSessionSnapshot.ObjectData object = snapshot.objects().get(i);
|
||||
LevelDefinition.PhysicalObject authored = definition.objects.get(i);
|
||||
if (!validSupport(object.supportIndex(), definition.platforms.size())
|
||||
|| object.type() != authored.type || object.mass() != authored.mass
|
||||
|| object.width() <= 0f || object.height() <= 0f) return false;
|
||||
}
|
||||
|
||||
startLevel(index);
|
||||
levelElapsed = snapshot.elapsedSeconds();
|
||||
@@ -2664,6 +2913,12 @@ public final class GameView extends View implements Runnable {
|
||||
corpseManager.transform(corpse, CorpseManager.Transformation.BURNED);
|
||||
}
|
||||
}
|
||||
if (!snapshot.objects().isEmpty()) {
|
||||
physicalObjects.clear();
|
||||
for (GameSessionSnapshot.ObjectData savedObject : snapshot.objects()) {
|
||||
physicalObjects.add(new PhysicalObjectState(savedObject));
|
||||
}
|
||||
}
|
||||
updateSignals(0f, false);
|
||||
|
||||
leftPressed = false;
|
||||
@@ -2721,6 +2976,8 @@ public final class GameView extends View implements Runnable {
|
||||
firstHazardY = firstHazardBox.y;
|
||||
}
|
||||
Body firstCorpse = corpses.isEmpty() ? null : corpses.get(0);
|
||||
PhysicalObjectState firstObject = physicalObjects.isEmpty()
|
||||
? null : physicalObjects.get(0);
|
||||
return new AutomationSnapshot(
|
||||
screen == Screen.COMPLETE,
|
||||
player.alive,
|
||||
@@ -2750,7 +3007,12 @@ public final class GameView extends View implements Runnable {
|
||||
firstCorpse != null && firstCorpse.crushed(),
|
||||
firstCorpse != null && firstCorpse.activatesPressure(),
|
||||
firstCorpse == null ? Float.NaN : firstCorpse.width(),
|
||||
firstCorpse == null ? Float.NaN : firstCorpse.height()
|
||||
firstCorpse == null ? Float.NaN : firstCorpse.height(),
|
||||
physicalObjects.size(),
|
||||
firstObject == null ? Float.NaN : firstObject.x,
|
||||
firstObject == null ? Float.NaN : firstObject.y,
|
||||
firstObject == null ? 0 : firstObject.mass,
|
||||
firstObject == null ? "" : firstObject.type.name()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Shared pressure-plate contract for living, retained, and authored objects. */
|
||||
public interface IWeightedObject {
|
||||
float weightedX();
|
||||
float weightedY();
|
||||
float weightedWidth();
|
||||
float weightedHeight();
|
||||
int pressureMass();
|
||||
boolean activatesPressure();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Mutable deterministic runtime for one authored weighted object. */
|
||||
public final class PhysicalObjectState implements IWeightedObject {
|
||||
final LevelDefinition.PhysicalObjectType type;
|
||||
final int mass;
|
||||
float x, y, width, height, vx, vy;
|
||||
boolean grounded;
|
||||
int supportIndex = -1;
|
||||
|
||||
public PhysicalObjectState(LevelDefinition.PhysicalObject definition) {
|
||||
if (definition == null) throw new IllegalArgumentException("definition is required");
|
||||
type = definition.type;
|
||||
mass = definition.mass;
|
||||
x = definition.box.x;
|
||||
y = definition.box.y;
|
||||
width = definition.box.width;
|
||||
height = definition.box.height;
|
||||
}
|
||||
|
||||
PhysicalObjectState(GameSessionSnapshot.ObjectData saved) {
|
||||
type = saved.type();
|
||||
mass = saved.mass();
|
||||
x = saved.x();
|
||||
y = saved.y();
|
||||
width = saved.width();
|
||||
height = saved.height();
|
||||
vx = saved.velocityX();
|
||||
vy = saved.velocityY();
|
||||
grounded = saved.grounded();
|
||||
supportIndex = saved.supportIndex();
|
||||
}
|
||||
|
||||
public PhysicalObjectState position(float valueX, float valueY) {
|
||||
x = valueX;
|
||||
y = valueY;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PhysicalObjectState velocity(float valueX, float valueY) {
|
||||
vx = valueX;
|
||||
vy = valueY;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PhysicalObjectState grounded(boolean value) { grounded = value; return this; }
|
||||
public PhysicalObjectState supportIndex(int value) { supportIndex = value; return this; }
|
||||
|
||||
public LevelDefinition.PhysicalObjectType type() { return type; }
|
||||
public float velocityX() { return vx; }
|
||||
public float velocityY() { return vy; }
|
||||
public boolean grounded() { return grounded; }
|
||||
public int supportIndex() { return supportIndex; }
|
||||
|
||||
@Override public float weightedX() { return x; }
|
||||
@Override public float weightedY() { return y; }
|
||||
@Override public float weightedWidth() { return width; }
|
||||
@Override public float weightedHeight() { return height; }
|
||||
@Override public int pressureMass() { return mass; }
|
||||
@Override public boolean activatesPressure() { return true; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Pure mass aggregation; it deliberately has no knowledge of object kinds. */
|
||||
public final class PressureMassResolver {
|
||||
private PressureMassResolver() {}
|
||||
|
||||
public static boolean active(LevelDefinition.Box plate, int requiredMass,
|
||||
Iterable<? extends IWeightedObject> objects) {
|
||||
int mass = 0;
|
||||
if (plate == null || objects == null || requiredMass <= 0) return false;
|
||||
for (IWeightedObject object : objects) {
|
||||
if (object == null || !object.activatesPressure()) continue;
|
||||
if (!intersects(object, plate)) continue;
|
||||
mass += Math.max(0, object.pressureMass());
|
||||
if (mass >= requiredMass) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static int contribution(LevelDefinition.Box plate, IWeightedObject object) {
|
||||
if (plate == null || object == null || !object.activatesPressure()) return 0;
|
||||
return intersects(object, plate) ? Math.max(0, object.pressureMass()) : 0;
|
||||
}
|
||||
|
||||
private static boolean intersects(IWeightedObject object, LevelDefinition.Box box) {
|
||||
return object.weightedX() < box.right()
|
||||
&& object.weightedX() + object.weightedWidth() > box.x
|
||||
&& object.weightedY() < box.bottom()
|
||||
&& object.weightedY() + object.weightedHeight() > box.y;
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,10 @@ import java.util.Map;
|
||||
public final class VersionedSessionCodec {
|
||||
private static final int MAGIC = 0x47534e50; // GSNP
|
||||
private static final int MAX_BODIES = 16;
|
||||
private static final int MAX_OBJECTS = 64;
|
||||
private static final int MAX_PLATFORMS = 64;
|
||||
private static final int MAX_MAP_ENTRIES = 128;
|
||||
public static final int CURRENT_VERSION = 3;
|
||||
public static final int CURRENT_VERSION = 4;
|
||||
|
||||
public enum Status { RESTORED, MIGRATED, UNSUPPORTED, CORRUPT }
|
||||
|
||||
@@ -45,6 +46,10 @@ public final class VersionedSessionCodec {
|
||||
return encodeWithVersion(snapshot, 2);
|
||||
}
|
||||
|
||||
static byte[] encodeLegacyV3(GameSessionSnapshot snapshot) {
|
||||
return encodeWithVersion(snapshot, 3);
|
||||
}
|
||||
|
||||
static byte[] encodeVersionForTest(int version) {
|
||||
try {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
@@ -80,6 +85,10 @@ public final class VersionedSessionCodec {
|
||||
|
||||
int bodyCount = checkedCount(input.readInt(), MAX_BODIES);
|
||||
for (int i = 0; i < bodyCount; i++) builder.addBody(readBody(input, version));
|
||||
if (version >= 4) {
|
||||
int objectCount = checkedCount(input.readInt(), MAX_OBJECTS);
|
||||
for (int i = 0; i < objectCount; i++) builder.addObject(readObject(input));
|
||||
}
|
||||
|
||||
int platformCount = checkedCount(input.readInt(), MAX_PLATFORMS);
|
||||
ArrayList<Float> motionTimes = new ArrayList<>(platformCount);
|
||||
@@ -115,6 +124,12 @@ public final class VersionedSessionCodec {
|
||||
for (GameSessionSnapshot.BodyData body : snapshot.bodies()) {
|
||||
writeBody(output, body, version);
|
||||
}
|
||||
if (version >= 4) {
|
||||
output.writeInt(snapshot.objects().size());
|
||||
for (GameSessionSnapshot.ObjectData object : snapshot.objects()) {
|
||||
writeObject(output, object);
|
||||
}
|
||||
}
|
||||
output.writeInt(snapshot.platformMotionTimes().size());
|
||||
for (Float time : snapshot.platformMotionTimes()) output.writeFloat(time);
|
||||
writeBooleanMap(output, snapshot.toggleSignals());
|
||||
@@ -194,6 +209,39 @@ public final class VersionedSessionCodec {
|
||||
crushed, burned, support, deathType);
|
||||
}
|
||||
|
||||
private static void writeObject(DataOutputStream output,
|
||||
GameSessionSnapshot.ObjectData object) throws IOException {
|
||||
output.writeUTF(object.type().name());
|
||||
output.writeFloat(object.x());
|
||||
output.writeFloat(object.y());
|
||||
output.writeFloat(object.width());
|
||||
output.writeFloat(object.height());
|
||||
output.writeFloat(object.velocityX());
|
||||
output.writeFloat(object.velocityY());
|
||||
output.writeInt(object.mass());
|
||||
output.writeBoolean(object.grounded());
|
||||
output.writeInt(object.supportIndex());
|
||||
}
|
||||
|
||||
private static GameSessionSnapshot.ObjectData readObject(DataInputStream input)
|
||||
throws IOException {
|
||||
String typeName = input.readUTF();
|
||||
LevelDefinition.PhysicalObjectType type =
|
||||
LevelDefinition.PhysicalObjectType.valueOf(typeName);
|
||||
float x = finite(input.readFloat());
|
||||
float y = finite(input.readFloat());
|
||||
float width = finite(input.readFloat());
|
||||
float height = finite(input.readFloat());
|
||||
float velocityX = finite(input.readFloat());
|
||||
float velocityY = finite(input.readFloat());
|
||||
int mass = input.readInt();
|
||||
if (mass <= 0) throw new IllegalArgumentException("Object mass must be positive");
|
||||
boolean grounded = input.readBoolean();
|
||||
int supportIndex = input.readInt();
|
||||
return new GameSessionSnapshot.ObjectData(type, x, y, width, height,
|
||||
velocityX, velocityY, mass, grounded, supportIndex);
|
||||
}
|
||||
|
||||
private static void writeBooleanMap(DataOutputStream output, Map<String, Boolean> values)
|
||||
throws IOException {
|
||||
output.writeInt(values.size());
|
||||
|
||||
@@ -80,6 +80,9 @@ public final class CorpseManagerTest {
|
||||
assertFalse(body.crushed());
|
||||
assertFalse(body.activatesPressure());
|
||||
assertTrue(manager.bodies().contains(body));
|
||||
assertTrue(body instanceof IWeightedObject);
|
||||
assertEquals(1, body.pressureMass());
|
||||
assertEquals(body.x(), body.weightedX(), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -6,6 +6,7 @@ import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
@@ -14,7 +15,7 @@ public final class LevelCatalogTest {
|
||||
@Test
|
||||
public void productionCatalogContainsHazardsAndTwoCorpseStateTeachingLevels() {
|
||||
List<LevelDefinition> levels = LevelCatalog.all();
|
||||
assertEquals(16, levels.size());
|
||||
assertEquals(17, levels.size());
|
||||
Set<String> titles = new HashSet<>();
|
||||
for (int i = 0; i < levels.size(); i++) {
|
||||
LevelDefinition level = levels.get(i);
|
||||
@@ -29,6 +30,16 @@ public final class LevelCatalogTest {
|
||||
assertTrue(levels.get(14).containsMechanic(LevelDefinition.HazardType.CRUSHER));
|
||||
assertTrue(levels.get(15).containsMechanic(LevelDefinition.HazardType.FIRE));
|
||||
assertFalse(levels.get(15).platforms.get(1).controlId.isEmpty());
|
||||
LevelDefinition weighted = levels.get(16);
|
||||
assertEquals(5, weighted.objects.size());
|
||||
Set<LevelDefinition.PhysicalObjectType> objectTypes = new HashSet<>();
|
||||
for (LevelDefinition.PhysicalObject object : weighted.objects) {
|
||||
objectTypes.add(object.type);
|
||||
}
|
||||
assertEquals(new HashSet<>(Arrays.asList(LevelDefinition.PhysicalObjectType.values())),
|
||||
objectTypes);
|
||||
assertEquals(9, weighted.switches.get(0).requiredMass);
|
||||
assertFalse(weighted.doors.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -214,6 +214,43 @@ public final class LevelJsonLoaderTest {
|
||||
assertEquals("explosion-blast", explosion.audioCue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void allApprovedWeightedObjectTypesLoadThroughOneMassContract() {
|
||||
String objects = "\"objects\":["
|
||||
+ "{\"type\":\"BOX\",\"box\":{\"x\":300,\"y\":800,\"width\":80,\"height\":80},\"mass\":1},"
|
||||
+ "{\"type\":\"ROCK\",\"box\":{\"x\":400,\"y\":820,\"width\":60,\"height\":60},\"mass\":2},"
|
||||
+ "{\"type\":\"BALL\",\"box\":{\"x\":500,\"y\":820,\"width\":60,\"height\":60},\"mass\":1},"
|
||||
+ "{\"type\":\"ICE_BLOCK\",\"box\":{\"x\":600,\"y\":800,\"width\":80,\"height\":80},\"mass\":2},"
|
||||
+ "{\"type\":\"COUNTERWEIGHT\",\"box\":{\"x\":700,\"y\":780,\"width\":80,\"height\":100},\"mass\":3}],";
|
||||
String json = validLevelJson(1).replace("\"platforms\":", objects + "\"platforms\":");
|
||||
|
||||
LevelDefinition level = LevelJsonLoader.load(new StringReader(json), "weighted.json");
|
||||
|
||||
assertEquals(5, level.objects.size());
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.BOX, level.objects.get(0).type);
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.ROCK, level.objects.get(1).type);
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.BALL, level.objects.get(2).type);
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.ICE_BLOCK, level.objects.get(3).type);
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.COUNTERWEIGHT, level.objects.get(4).type);
|
||||
assertEquals(3, level.objects.get(4).mass);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void weightedObjectRejectsNonPositiveMassWithItsJsonPath() {
|
||||
String object = "\"objects\":[{\"type\":\"BOX\","
|
||||
+ "\"box\":{\"x\":300,\"y\":800,\"width\":80,\"height\":80},\"mass\":0}],";
|
||||
String json = validLevelJson(1).replace("\"platforms\":", object + "\"platforms\":");
|
||||
|
||||
try {
|
||||
LevelJsonLoader.load(new StringReader(json), "massless.json");
|
||||
fail("A pressure object with no mass must fail closed");
|
||||
} catch (IllegalArgumentException error) {
|
||||
assertTrue(error.getMessage(), error.getMessage().contains(
|
||||
"massless.json.objects[0].mass"));
|
||||
assertTrue(error.getMessage(), error.getMessage().contains("must be positive"));
|
||||
}
|
||||
}
|
||||
|
||||
static String validLevelJson(int formatVersion) {
|
||||
return "{"
|
||||
+ "\"formatVersion\":" + formatVersion + ","
|
||||
|
||||
@@ -8,8 +8,8 @@ import org.junit.Test;
|
||||
|
||||
public final class LevelPageTest {
|
||||
@Test
|
||||
public void sixteenLevelsUseTwoBoundedTenCardPages() {
|
||||
LevelPage page = new LevelPage(16, 10);
|
||||
public void seventeenLevelsUseTwoBoundedTenCardPages() {
|
||||
LevelPage page = new LevelPage(17, 10);
|
||||
|
||||
assertEquals(2, page.pageCount());
|
||||
assertEquals(0, page.firstIndex());
|
||||
@@ -19,7 +19,7 @@ public final class LevelPageTest {
|
||||
|
||||
page.next();
|
||||
assertEquals(10, page.firstIndex());
|
||||
assertEquals(16, page.endExclusive());
|
||||
assertEquals(17, page.endExclusive());
|
||||
assertTrue(page.hasPrevious());
|
||||
assertFalse(page.hasNext());
|
||||
|
||||
@@ -29,5 +29,7 @@ public final class LevelPageTest {
|
||||
assertEquals(0, page.pageIndex());
|
||||
page.selectIndex(15);
|
||||
assertEquals(1, page.pageIndex());
|
||||
page.selectIndex(16);
|
||||
assertEquals(1, page.pageIndex());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class PhysicalObjectStateTest {
|
||||
@Test
|
||||
public void authoredObjectPublishesStableMassGeometryAndRuntimeState() {
|
||||
LevelDefinition.PhysicalObject definition = new LevelDefinition.PhysicalObject(
|
||||
LevelDefinition.PhysicalObjectType.COUNTERWEIGHT,
|
||||
new LevelDefinition.Box(420f, 760f, 80f, 120f), 3);
|
||||
|
||||
PhysicalObjectState state = new PhysicalObjectState(definition);
|
||||
state.velocity(120f, -30f).grounded(true).supportIndex(2);
|
||||
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.COUNTERWEIGHT, state.type());
|
||||
assertEquals(420f, state.weightedX(), 0f);
|
||||
assertEquals(760f, state.weightedY(), 0f);
|
||||
assertEquals(80f, state.weightedWidth(), 0f);
|
||||
assertEquals(120f, state.weightedHeight(), 0f);
|
||||
assertEquals(3, state.pressureMass());
|
||||
assertTrue(state.activatesPressure());
|
||||
assertEquals(120f, state.velocityX(), 0f);
|
||||
assertEquals(-30f, state.velocityY(), 0f);
|
||||
assertTrue(state.grounded());
|
||||
assertEquals(2, state.supportIndex());
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ public final class VersionedSessionCodecTest {
|
||||
assertTrue(restored.bodies().get(1).conductive());
|
||||
assertTrue(restored.bodies().get(0).crushed());
|
||||
assertTrue(restored.bodies().get(1).burned());
|
||||
assertEquals(2, restored.objects().size());
|
||||
assertEquals(LevelDefinition.PhysicalObjectType.BOX,
|
||||
restored.objects().get(0).type());
|
||||
assertEquals(3, restored.objects().get(1).mass());
|
||||
assertEquals(Arrays.asList(1.25f, 3.5f), restored.platformMotionTimes());
|
||||
assertTrue(restored.toggleSignals().get("gate"));
|
||||
assertEquals(2.25f, restored.timedSignals().get("clock"), 0f);
|
||||
@@ -58,6 +62,18 @@ public final class VersionedSessionCodecTest {
|
||||
assertTrue(decoded.snapshot().previousSwitchContacts().get("gate@420.0"));
|
||||
assertFalse(decoded.snapshot().bodies().get(0).crushed());
|
||||
assertFalse(decoded.snapshot().bodies().get(1).burned());
|
||||
assertTrue(decoded.snapshot().objects().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyV3MigratesWithSafeEmptyPhysicalObjectRuntime() {
|
||||
VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode(
|
||||
VersionedSessionCodec.encodeLegacyV3(sampleSnapshot()));
|
||||
|
||||
assertEquals(VersionedSessionCodec.Status.MIGRATED, decoded.status());
|
||||
assertTrue(decoded.snapshot().bodies().get(0).crushed());
|
||||
assertTrue(decoded.snapshot().bodies().get(1).burned());
|
||||
assertTrue(decoded.snapshot().objects().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -105,6 +121,12 @@ public final class VersionedSessionCodecTest {
|
||||
720f, 826f, 72f, 54f, 0f, 0f,
|
||||
false, true, true, true, false, true,
|
||||
-1, "FIRE"))
|
||||
.addObject(new GameSessionSnapshot.ObjectData(
|
||||
LevelDefinition.PhysicalObjectType.BOX,
|
||||
300f, 800f, 80f, 80f, 20f, 0f, 1, true, -1))
|
||||
.addObject(new GameSessionSnapshot.ObjectData(
|
||||
LevelDefinition.PhysicalObjectType.COUNTERWEIGHT,
|
||||
700f, 780f, 80f, 100f, 0f, 0f, 3, true, 1))
|
||||
.platformMotionTimes(Arrays.asList(1.25f, 3.5f))
|
||||
.toggleSignal("gate", true)
|
||||
.timedSignal("clock", 2.25f)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class WeightedPressureResolverTest {
|
||||
@Test
|
||||
public void plateUsesOneMassContractForPlayerCorpseAndPhysicalObject() {
|
||||
LevelDefinition.Box plate = new LevelDefinition.Box(100f, 200f, 180f, 20f);
|
||||
IWeightedObject player = item(110f, 150f, 62f, 100f, 1, true);
|
||||
IWeightedObject corpse = item(170f, 166f, 72f, 54f, 1, true);
|
||||
IWeightedObject counterweight = item(230f, 140f, 70f, 80f, 3, true);
|
||||
|
||||
assertFalse(PressureMassResolver.active(
|
||||
plate, 6, Arrays.asList(player, corpse, counterweight)));
|
||||
assertTrue(PressureMassResolver.active(
|
||||
plate, 5, Arrays.asList(player, corpse, counterweight)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void inactiveAndNonIntersectingObjectsNeverContributeMass() {
|
||||
LevelDefinition.Box plate = new LevelDefinition.Box(100f, 200f, 180f, 20f);
|
||||
IWeightedObject burnedCorpse = item(120f, 166f, 72f, 54f, 5, false);
|
||||
IWeightedObject distantRock = item(500f, 160f, 60f, 60f, 5, true);
|
||||
|
||||
assertFalse(PressureMassResolver.active(
|
||||
plate, 1, Arrays.asList(burnedCorpse, distantRock)));
|
||||
}
|
||||
|
||||
private static IWeightedObject item(float x, float y, float width, float height,
|
||||
int mass, boolean active) {
|
||||
return new IWeightedObject() {
|
||||
@Override public float weightedX() { return x; }
|
||||
@Override public float weightedY() { return y; }
|
||||
@Override public float weightedWidth() { return width; }
|
||||
@Override public float weightedHeight() { return height; }
|
||||
@Override public int pressureMass() { return mass; }
|
||||
@Override public boolean activatesPressure() { return active; }
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user