Добавить общую систему весовых объектов

This commit is contained in:
2026-08-19 07:40:11 +03:00
parent 19bb64edfb
commit 259fcd316b
32 changed files with 1017 additions and 34 deletions
@@ -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; }
};
}
}