Выделить CorpseManager

This commit is contained in:
2026-08-19 03:43:20 +03:00
parent 62ee6877f9
commit cc3b5902cc
8 changed files with 436 additions and 69 deletions
@@ -0,0 +1,74 @@
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 CorpseManagerIntegrationTest {
@Test
public void deathRegistersAndRestartResetsTheOwnedBodyLifecycle() throws Exception {
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
try (ActivityScenario<MainActivity> activity =
ActivityScenario.launch(MainActivity.class)) {
GameView[] view = new GameView[1];
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
instrumentation.runOnMainSync(() -> {
view[0].startLevelForAutomation(10);
view[0].setAutomationInput(false, true, false);
});
GameView.AutomationSnapshot died = awaitBody(instrumentation, view[0], 8_000L);
CorpseManager.Diagnostics before = diagnostics(instrumentation, view[0]);
assertEquals(1, died.corpseCount());
assertEquals(1L, before.registered());
assertEquals(0L, before.evicted());
instrumentation.runOnMainSync(view[0]::restartLevelForAutomation);
GameView.AutomationSnapshot restarted = snapshot(instrumentation, view[0]);
CorpseManager.Diagnostics after = diagnostics(instrumentation, view[0]);
assertEquals(0, restarted.corpseCount());
assertEquals(before.resets() + 1L, after.resets());
assertEquals(before.clearedByReset() + 1L, after.clearedByReset());
assertTrue(restarted.playerAlive());
}
}
private static GameView.AutomationSnapshot awaitBody(
Instrumentation instrumentation, GameView view, long timeoutMillis)
throws Exception {
long deadline = System.currentTimeMillis() + timeoutMillis;
GameView.AutomationSnapshot current = snapshot(instrumentation, view);
while (System.currentTimeMillis() < deadline) {
current = snapshot(instrumentation, view);
if (current.corpseCount() == 1) return current;
Thread.sleep(24L);
}
throw new AssertionError("Body was not registered; deaths=" + current.deaths()
+ ", corpses=" + current.corpseCount());
}
private static GameView.AutomationSnapshot snapshot(
Instrumentation instrumentation, GameView view) {
GameView.AutomationSnapshot[] result = new GameView.AutomationSnapshot[1];
instrumentation.runOnMainSync(() -> result[0] = view.automationSnapshot());
return result[0];
}
private static CorpseManager.Diagnostics diagnostics(
Instrumentation instrumentation, GameView view) {
CorpseManager.Diagnostics[] result = new CorpseManager.Diagnostics[1];
instrumentation.runOnMainSync(
() -> result[0] = view.corpseDiagnosticsForAutomation());
return result[0];
}
}
@@ -0,0 +1,172 @@
package com.projectsacrifice.game;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** Owns the bounded lifecycle of puzzle bodies for one active level. */
public final class CorpseManager {
public enum Transformation {
PINNED,
FROZEN,
CONDUCTIVE
}
public static final class Body {
final long id;
final String deathType;
float x;
float y;
float width = 72f;
float height = 54f;
float vx;
float vy;
boolean frozen;
boolean conductive;
boolean pinned;
boolean grounded;
int supportIndex = -1;
private Body(long id, String deathType, float x, float y) {
this.id = id;
this.deathType = deathType;
this.x = x;
this.y = y;
}
public long id() { return id; }
public String deathType() { return deathType; }
public float x() { return x; }
public float y() { return y; }
public float width() { return width; }
public float height() { return height; }
public float velocityX() { return vx; }
public float velocityY() { return vy; }
public boolean frozen() { return frozen; }
public boolean conductive() { return conductive; }
public boolean pinned() { return pinned; }
public boolean grounded() { return grounded; }
public int supportIndex() { return supportIndex; }
public Body position(float x, float y) {
this.x = x;
this.y = y;
return this;
}
public Body size(float width, float height) {
if (width <= 0f || height <= 0f) {
throw new IllegalArgumentException("body size must be positive");
}
this.width = width;
this.height = height;
return this;
}
public Body velocity(float x, float y) {
vx = x;
vy = y;
return this;
}
public Body grounded(boolean value) {
grounded = value;
return this;
}
public Body supportIndex(int value) {
supportIndex = value;
return this;
}
}
public static final class Diagnostics {
private final long registered;
private final long evicted;
private final long removed;
private final long resets;
private final long clearedByReset;
private Diagnostics(long registered, long evicted, long removed,
long resets, long clearedByReset) {
this.registered = registered;
this.evicted = evicted;
this.removed = removed;
this.resets = resets;
this.clearedByReset = clearedByReset;
}
public long registered() { return registered; }
public long evicted() { return evicted; }
public long removed() { return removed; }
public long resets() { return resets; }
public long clearedByReset() { return clearedByReset; }
}
private final int maximumBodies;
private final ArrayList<Body> bodies = new ArrayList<>();
private long nextId = 1L;
private long registered;
private long evicted;
private long removed;
private long resets;
private long clearedByReset;
public CorpseManager(int maximumBodies) {
if (maximumBodies < 1) throw new IllegalArgumentException("maximumBodies must be positive");
this.maximumBodies = maximumBodies;
}
public Body register(String deathType, float x, float y) {
Body body = new Body(nextId++, safeDeathType(deathType), x, y);
bodies.add(body);
registered++;
if (bodies.size() > maximumBodies) {
bodies.remove(0);
evicted++;
}
return body;
}
public int count() { return bodies.size(); }
public boolean remove(Body body) {
boolean changed = body != null && bodies.remove(body);
if (changed) removed++;
return changed;
}
public boolean transform(Body body, Transformation transformation) {
if (body == null || transformation == null || !bodies.contains(body)) return false;
switch (transformation) {
case PINNED:
body.pinned = true;
break;
case FROZEN:
body.frozen = true;
break;
case CONDUCTIVE:
body.conductive = true;
break;
}
return true;
}
public void reset() {
clearedByReset += bodies.size();
bodies.clear();
resets++;
}
public List<Body> bodies() {
return Collections.unmodifiableList(bodies);
}
public Diagnostics diagnostics() {
return new Diagnostics(registered, evicted, removed, resets, clearedByReset);
}
private static String safeDeathType(String value) {
return value == null || value.trim().isEmpty() ? "GENERIC" : value;
}
}
@@ -1,5 +1,7 @@
package com.projectsacrifice.game;
import com.projectsacrifice.game.CorpseManager.Body;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
@@ -190,18 +192,6 @@ public final class GameView extends View implements Runnable {
int facing = 1;
}
private static final class Corpse {
float x, y, width = 72f, height = 54f, vx, vy;
boolean frozen;
boolean conductive;
boolean pinned;
boolean grounded;
int supportIndex = -1;
DeathType type;
RectF rect() { return new RectF(x, y, x + width, y + height); }
}
private static final class PlatformState {
float x, y, previousX, previousY, motionTime;
final LevelDefinition.Platform definition;
@@ -235,7 +225,8 @@ public final class GameView extends View implements Runnable {
private final Player player = new Player();
private final CharacterStateMachine characterStateMachine =
new CharacterStateMachine(PHYSICS);
private final ArrayList<Corpse> corpses = new ArrayList<>();
private final CorpseManager corpseManager = new CorpseManager(MAX_CORPSES);
private final List<Body> corpses = corpseManager.bodies();
private final ArrayList<PlatformState> platformStates = new ArrayList<>();
private final Map<String, Boolean> toggleSignals = new HashMap<>();
private final Map<String, Float> timedSignals = new HashMap<>();
@@ -400,7 +391,7 @@ public final class GameView extends View implements Runnable {
userPaused = false;
pointers.clear();
deriveControls();
corpses.clear();
corpseManager.reset();
platformStates.clear();
toggleSignals.clear();
timedSignals.clear();
@@ -517,7 +508,7 @@ 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++;
for (Corpse corpse : corpses) {
for (Body corpse : corpses) {
if (intersects(corpse.x, corpse.y, corpse.width, corpse.height, item.box)) mass++;
}
return mass >= item.requiredMass;
@@ -533,7 +524,7 @@ public final class GameView extends View implements Runnable {
}
for (LevelDefinition.Hazard hazard : level.hazards) {
if (id.equals(hazard.relayId)) {
for (Corpse corpse : corpses) {
for (Body corpse : corpses) {
if (corpse.conductive && intersects(corpse.x, corpse.y, corpse.width, corpse.height, hazard.box)) return true;
}
}
@@ -566,7 +557,7 @@ public final class GameView extends View implements Runnable {
player.x += support.x - support.previousX;
player.y += support.y - support.previousY;
}
for (Corpse corpse : corpses) {
for (Body corpse : corpses) {
if (!corpse.frozen && corpse.supportIndex >= 0 && corpse.supportIndex < platformStates.size()) {
PlatformState support = platformStates.get(corpse.supportIndex);
corpse.x += support.x - support.previousX;
@@ -650,8 +641,8 @@ public final class GameView extends View implements Runnable {
player.vy = 0f;
current = playerRect();
}
for (Corpse corpse : corpses) {
RectF solid = corpse.rect();
for (Body corpse : corpses) {
RectF solid = corpseRect(corpse);
if (!RectF.intersects(current, solid)) continue;
if (amount >= 0f) {
player.y = solid.top - PLAYER_HEIGHT;
@@ -666,7 +657,7 @@ public final class GameView extends View implements Runnable {
}
private void updateCorpses(float dt) {
for (Corpse corpse : corpses) {
for (Body corpse : corpses) {
if (corpse.frozen || corpse.pinned) continue;
corpse.vy += GRAVITY * dt;
corpse.grounded = false;
@@ -680,7 +671,7 @@ public final class GameView extends View implements Runnable {
}
}
private boolean settleCorpseOnPressurePlate(Corpse corpse) {
private boolean settleCorpseOnPressurePlate(Body corpse) {
for (LevelDefinition.Switch item : level.switches) {
if (item.type != LevelDefinition.SwitchType.PRESSURE) continue;
if (!intersects(corpse.x, corpse.y, corpse.width, corpse.height, item.box)) continue;
@@ -691,26 +682,26 @@ public final class GameView extends View implements Runnable {
}
corpse.vx = 0f;
corpse.vy = 0f;
corpse.pinned = true;
corpseManager.transform(corpse, CorpseManager.Transformation.PINNED);
return true;
}
return false;
}
private void resolveCorpseHorizontal(Corpse corpse) {
RectF current = corpse.rect();
private void resolveCorpseHorizontal(Body corpse) {
RectF current = corpseRect(corpse);
for (RectF solid : solidRects(false)) {
if (!RectF.intersects(current, solid)) continue;
if (corpse.vx > 0f) corpse.x = solid.left - corpse.width;
else if (corpse.vx < 0f) corpse.x = solid.right;
corpse.vx *= -0.18f;
current = corpse.rect();
current = corpseRect(corpse);
}
}
private void resolveCorpseVertical(Corpse corpse) {
private void resolveCorpseVertical(Body corpse) {
corpse.supportIndex = -1;
RectF current = corpse.rect();
RectF current = corpseRect(corpse);
for (int i = 0; i < platformStates.size(); i++) {
RectF solid = platformStates.get(i).rect();
if (!RectF.intersects(current, solid)) continue;
@@ -723,7 +714,7 @@ public final class GameView extends View implements Runnable {
corpse.y = solid.bottom;
corpse.vy *= -0.1f;
}
current = corpse.rect();
current = corpseRect(corpse);
}
for (LevelDefinition.Door door : level.doors) {
if (doorOpen(door)) continue;
@@ -737,7 +728,7 @@ public final class GameView extends View implements Runnable {
corpse.y = solid.bottom;
corpse.vy *= -0.1f;
}
current = corpse.rect();
current = corpseRect(corpse);
}
}
@@ -745,7 +736,7 @@ public final class GameView extends View implements Runnable {
ArrayList<RectF> result = new ArrayList<>();
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 (Corpse corpse : corpses) result.add(corpse.rect());
if (includeCorpses) for (Body corpse : corpses) result.add(corpseRect(corpse));
return result;
}
@@ -778,10 +769,8 @@ public final class GameView extends View implements Runnable {
cameraController.impact(type == DeathType.PIT ? 12f : 24f);
if (type != DeathType.PIT) {
Corpse corpse = new Corpse();
corpse.type = type;
corpse.x = player.x;
corpse.y = player.y + PLAYER_HEIGHT - corpse.height;
Body corpse = corpseManager.register(type.name(), player.x,
player.y + PLAYER_HEIGHT - 54f);
if (source != null) {
corpse.vx = source.impulseX;
corpse.vy = source.impulseY;
@@ -789,23 +778,21 @@ public final class GameView extends View implements Runnable {
if (type == DeathType.SPIKE && source != null) {
corpse.y = source.box.y - corpse.height;
corpse.vx = corpse.vy = 0f;
corpse.pinned = true;
corpseManager.transform(corpse, CorpseManager.Transformation.PINNED);
} else if (type == DeathType.FREEZE && source != null) {
corpse.width = 104f;
corpse.height = 104f;
corpse.x = source.box.x - 2f;
corpse.y = 880f - corpse.height;
corpse.vx = corpse.vy = 0f;
corpse.frozen = true;
corpseManager.transform(corpse, CorpseManager.Transformation.FROZEN);
} else if (type == DeathType.ELECTRIC && source != null) {
corpse.conductive = true;
corpse.x = source.box.x + (source.box.width - corpse.width) * 0.5f;
corpse.y = 880f - corpse.height;
corpse.vx = corpse.vy = 0f;
corpse.pinned = true;
corpseManager.transform(corpse, CorpseManager.Transformation.CONDUCTIVE);
corpseManager.transform(corpse, CorpseManager.Transformation.PINNED);
}
corpses.add(corpse);
while (corpses.size() > MAX_CORPSES) corpses.remove(0);
}
}
@@ -841,6 +828,11 @@ public final class GameView extends View implements Runnable {
return new RectF(player.x, player.y, player.x + PLAYER_WIDTH, player.y + PLAYER_HEIGHT);
}
private static RectF corpseRect(Body corpse) {
return new RectF(corpse.x, corpse.y,
corpse.x + corpse.width, corpse.y + corpse.height);
}
private static RectF rect(LevelDefinition.Box box) {
return new RectF(box.x, box.y, box.right(), box.bottom());
}
@@ -902,7 +894,7 @@ public final class GameView extends View implements Runnable {
drawHazards(canvas);
drawDoors(canvas);
drawGoal(canvas);
for (Corpse corpse : corpses) drawCorpse(canvas, corpse);
for (Body corpse : corpses) drawCorpse(canvas, corpse);
VolunteerRig.Skin skin = VolunteerRig.Skin.forLevel(level.number);
if (player.alive) {
VolunteerRig.Motion motion = VolunteerRig.motionFor(characterStateMachine.state());
@@ -1140,7 +1132,7 @@ public final class GameView extends View implements Runnable {
canvas.drawCircle(box.centerX() - 6f, box.centerY() - 7f, 7f, paint);
}
private void drawCorpse(Canvas canvas, Corpse corpse) {
private void drawCorpse(Canvas canvas, Body corpse) {
canvas.save();
float cx = corpse.x + corpse.width * 0.5f;
float cy = corpse.y + corpse.height * 0.5f;
@@ -2280,12 +2272,12 @@ public final class GameView extends View implements Runnable {
player.x, player.y, player.vx, player.vy,
player.alive, player.onGround, player.coyote,
player.jumpBuffer, player.supportIndex, player.facing));
for (Corpse corpse : corpses) {
for (Body corpse : corpses) {
builder.addBody(new GameSessionSnapshot.BodyData(
corpse.x, corpse.y, corpse.width, corpse.height,
corpse.vx, corpse.vy, corpse.frozen, corpse.conductive,
corpse.pinned, corpse.grounded, corpse.supportIndex,
corpse.type == null ? DeathType.SPIKE.name() : corpse.type.name()));
corpse.deathType()));
}
ArrayList<Float> motionTimes = new ArrayList<>(platformStates.size());
for (PlatformState state : platformStates) motionTimes.add(state.motionTime);
@@ -2353,22 +2345,22 @@ public final class GameView extends View implements Runnable {
characterStateMachine.reset(player.alive, player.onGround,
player.vx, player.vy, player.facing);
corpses.clear();
for (GameSessionSnapshot.BodyData savedBody : snapshot.bodies()) {
Corpse corpse = new Corpse();
corpse.x = savedBody.x();
corpse.y = savedBody.y();
corpse.width = savedBody.width();
corpse.height = savedBody.height();
corpse.vx = savedBody.velocityX();
corpse.vy = savedBody.velocityY();
corpse.frozen = savedBody.frozen();
corpse.conductive = savedBody.conductive();
corpse.pinned = savedBody.pinned();
corpse.grounded = savedBody.grounded();
corpse.supportIndex = savedBody.supportIndex();
corpse.type = DeathType.valueOf(savedBody.deathType());
corpses.add(corpse);
Body corpse = corpseManager.register(savedBody.deathType(),
savedBody.x(), savedBody.y())
.size(savedBody.width(), savedBody.height())
.velocity(savedBody.velocityX(), savedBody.velocityY())
.grounded(savedBody.grounded())
.supportIndex(savedBody.supportIndex());
if (savedBody.frozen()) {
corpseManager.transform(corpse, CorpseManager.Transformation.FROZEN);
}
if (savedBody.conductive()) {
corpseManager.transform(corpse, CorpseManager.Transformation.CONDUCTIVE);
}
if (savedBody.pinned()) {
corpseManager.transform(corpse, CorpseManager.Transformation.PINNED);
}
}
leftPressed = false;
@@ -2452,4 +2444,8 @@ public final class GameView extends View implements Runnable {
public void triggerStunForAutomation() {
characterStateMachine.stun();
}
public CorpseManager.Diagnostics corpseDiagnosticsForAutomation() {
return corpseManager.diagnostics();
}
}
@@ -0,0 +1,71 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public final class CorpseManagerTest {
@Test
public void registeringBeyondTheBudgetEvictsTheOldestBodyAndReportsIt() {
CorpseManager manager = new CorpseManager(2);
CorpseManager.Body first = manager.register("SPIKE", 10f, 20f);
CorpseManager.Body second = manager.register("SAW", 30f, 40f);
CorpseManager.Body third = manager.register("FREEZE", 50f, 60f);
assertEquals(2, manager.count());
assertSame(second, manager.bodies().get(0));
assertSame(third, manager.bodies().get(1));
assertEquals(3L, manager.diagnostics().registered());
assertEquals(1L, manager.diagnostics().evicted());
assertEquals(first.id() + 1L, second.id());
}
@Test
public void removeAndResetAreIdempotentAndHaveSeparateDiagnostics() {
CorpseManager manager = new CorpseManager(3);
CorpseManager.Body first = manager.register("SPIKE", 10f, 20f);
manager.register("SAW", 30f, 40f);
assertTrue(manager.remove(first));
assertFalse(manager.remove(first));
manager.reset();
assertEquals(0, manager.count());
assertEquals(1L, manager.diagnostics().removed());
assertEquals(1L, manager.diagnostics().resets());
assertEquals(1L, manager.diagnostics().clearedByReset());
}
@Test
public void ownedBodiesExposePhysicsFactsAndComposableTransformations() {
CorpseManager manager = new CorpseManager(3);
CorpseManager.Body body = manager.register("ELECTRIC", 10f, 20f)
.position(30f, 40f)
.size(104f, 54f)
.velocity(12f, -8f)
.grounded(true)
.supportIndex(2);
assertTrue(manager.transform(body, CorpseManager.Transformation.FROZEN));
assertTrue(manager.transform(body, CorpseManager.Transformation.CONDUCTIVE));
assertTrue(manager.transform(body, CorpseManager.Transformation.PINNED));
assertEquals(30f, body.x(), 0f);
assertEquals(40f, body.y(), 0f);
assertEquals(104f, body.width(), 0f);
assertEquals(54f, body.height(), 0f);
assertEquals(12f, body.velocityX(), 0f);
assertEquals(-8f, body.velocityY(), 0f);
assertTrue(body.frozen());
assertTrue(body.conductive());
assertTrue(body.pinned());
assertTrue(body.grounded());
assertEquals(2, body.supportIndex());
CorpseManager.Body foreign = new CorpseManager(1).register("SAW", 0f, 0f);
assertFalse(manager.transform(foreign, CorpseManager.Transformation.FROZEN));
}
}