Выделить 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
+2 -2
View File
@@ -49,10 +49,10 @@
{"_type":"issue","id":"goon-game-ui-vslice","title":"Базовый HUD и multitouch controls","description":"В APK есть направления, jump, restart, pause, deaths и timer.","acceptance_criteria":"Управление работает одновременно двумя касаниями.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:00Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:04Z","closed_at":"2026-08-17T21:41:21Z","close_reason":"Базовый HUD и multitouch controls реализованы.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-ui-vslice","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:22Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-camera-polish","title":"Доработать production camera controller","description":"Добавить vertical damping, room transitions, focus target и управляемый shake.","acceptance_criteria":"Камера не скрывает обязательные объекты и не вызывает дискомфорт.","notes":"Stage 5 RC: bounded horizontal/vertical damping, mechanism focus, impact shake, reduced-motion persistence; camera excluded from physics signatures; 35/35 Android gate GREEN.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:39:50Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:01:45Z","closed_at":"2026-08-18T23:01:45Z","close_reason":"Production camera behavior and reduced-motion acceptance are implemented and regression-tested.","metadata":{"stages":5,"primary_stage":5},"labels":["camera","primary-stage-5","stage-5","vertical-slice"],"dependencies":[{"issue_id":"goon-game-camera-polish","depends_on_id":"goon-game-e06","type":"parent-child","created_at":"2026-08-18T00:43:16Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-camera-basic","title":"Базовая камера с look-ahead и bounds","description":"В текущем runtime реализовано сглаженное горизонтальное слежение.","acceptance_criteria":"Камера работает во всех 10 уровнях.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:45Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:04Z","closed_at":"2026-08-17T21:41:18Z","close_reason":"Базовое сглаженное слежение реализовано.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-camera-basic","depends_on_id":"goon-game-e06","type":"parent-child","created_at":"2026-08-18T00:43:15Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-puzzle-events","title":"Выделить event-driven PuzzleSystem","description":"Заменить прямую проверку сигналов универсальными каналами событий и явными состояниями DoorController.","acceptance_criteria":"Кнопки, двери и платформы связываются данными; integration-тесты проходят.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:41Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:27Z","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","primary-stage-6","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-puzzle-events","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:13Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-puzzle-events","title":"Выделить event-driven PuzzleSystem","description":"Заменить прямую проверку сигналов универсальными каналами событий и явными состояниями DoorController.","acceptance_criteria":"Кнопки, двери и платформы связываются данными; integration-тесты проходят.","notes":"2026-08-19: claimed as the next Stage 6 foundation slice after CorpseManager. Planned boundary: deterministic event/signal/door state model, data-driven bindings, GameView adapter, RED-GREEN integration, no level-solution changes.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:39:41Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T00:43:02Z","started_at":"2026-08-19T00:43:02Z","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","primary-stage-6","puzzle","stage-6"],"dependencies":[{"issue_id":"goon-game-puzzle-events","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:13Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-puzzle-vslice","title":"Базовые puzzle objects vertical slice","description":"Работают pressure/toggle/timed switches, doors и moving platforms.","acceptance_criteria":"Существующие уровни используют все базовые типы.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:39Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:05Z","closed_at":"2026-08-17T21:41:17Z","close_reason":"Базовые кнопки, двери и платформы реализованы.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-puzzle-vslice","depends_on_id":"goon-game-e05","type":"parent-child","created_at":"2026-08-18T00:43:11Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-hazards-vslice","title":"Опасности vertical slice","description":"Реализованы spikes, saw, pit, freeze и electricity.","acceptance_criteria":"Опасности присутствуют в каталоге 10 уровней и не ломают restart.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:35Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:05Z","closed_at":"2026-08-17T21:41:16Z","close_reason":"Базовый набор опасностей присутствует в 10 уровнях.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-hazards-vslice","depends_on_id":"goon-game-e04","type":"parent-child","created_at":"2026-08-18T00:43:09Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-corpse-manager","title":"Выделить полноценный CorpseManager","description":"Убрать управление телами из монолитного GameView; добавить register/remove/transform/reset, лимит и диагностические счётчики.","acceptance_criteria":"Система имеет отдельный контракт, тесты и не меняет решения существующих уровней.","notes":"2026-08-19: next Stage 6 foundation slice after character state machine. Planned boundary: deep public corpse lifecycle contract, GameView extraction without level-solution changes, test-first register/remove/transform/reset/limit/diagnostics.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:39:27Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T00:19:59Z","started_at":"2026-08-19T00:19:59Z","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","corpse","primary-stage-6","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-manager","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:04Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-corpse-manager","title":"Выделить полноценный CorpseManager","description":"Убрать управление телами из монолитного GameView; добавить register/remove/transform/reset, лимит и диагностические счётчики.","acceptance_criteria":"Система имеет отдельный контракт, тесты и не меняет решения существующих уровней.","notes":"2026-08-19: next Stage 6 foundation slice after character state machine. Planned boundary: deep public corpse lifecycle contract, GameView extraction without level-solution changes, test-first register/remove/transform/reset/limit/diagnostics.\n2026-08-19: completed test-first. CorpseManager now owns bounded register/remove/reset/FIFO eviction, composable pinned/frozen/conductive transformations, body save/restore and immutable diagnostics. GameView remains the physics adapter; no physics, geometry or level-signature changes. Final gates: 84 JVM tests; lintDebug/assembleDebug/assembleDebugAndroidTest GREEN; Android 16 unfiltered 37/37, 0 failed/0 skipped, 427.237s; APK ZIP valid, SHA-256 982fcf312f10bf23feb3a8794b708b61002012a3706c94ebb4320fefb36fb889. Evidence: docs/STAGE6_CORPSE_MANAGER.md.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:39:27Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T00:43:01Z","started_at":"2026-08-19T00:19:59Z","closed_at":"2026-08-19T00:43:01Z","close_reason":"Acceptance criteria выполнены: отдельный lifecycle-контракт интегрирован, существующие решения сохранены, полная Android-матрица 37/37 зелёная","metadata":{"stages":6,"primary_stage":6},"labels":["architecture","corpse","primary-stage-6","stage-6"],"dependencies":[{"issue_id":"goon-game-corpse-manager","depends_on_id":"goon-game-e02","type":"parent-child","created_at":"2026-08-18T00:43:04Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"goon-game-character-states","title":"Завершить state machine персонажа","description":"Добавить явные Interact, Stunned, Turn, Land и animation-state переходы; вынести coyote/input buffer в конфиг.","acceptance_criteria":"Все переходы детерминированы, визуально различимы и имеют тесты.","notes":"2026-08-19: Stage 6 started after accepted Vertical Slice. First vertical slice: explicit deterministic character state machine (Idle/Run/Turn/Jump/Fall/Land/Interact/Stunned/Death), movement timings sourced from PhysicsConfig, GameView integration without physics changes, public automation state, JVM contract and full build gate.\n2026-08-19: TDD complete. Explicit Idle/Run/Turn/Jump/Fall/Land/Interact/Stunned/Death model, configured timings, non-color poses, GameView spawn/update/death/restore/switch/automation integration. Final exact gates: 81 JVM; lint/build; Android 16 unfiltered 36/36 in 720.578s; APK ZIP and SHA d25b7dd0a06cead0d514539773acd0a84ed92bf7c2510b9b880ded2f66508581. Evidence: docs/STAGE6_CHARACTER_STATES.md.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:39:20Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T00:19:57Z","started_at":"2026-08-18T23:20:45Z","closed_at":"2026-08-19T00:19:57Z","close_reason":"All character transitions are deterministic, visually distinct, configured and proven by JVM plus exact Android 16 integration/regression gates.","metadata":{"stages":6,"primary_stage":6},"labels":["character","future","primary-stage-6","stage-6"],"dependencies":[{"issue_id":"goon-game-character-states","depends_on_id":"goon-game-e01","type":"parent-child","created_at":"2026-08-18T00:43:02Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-character-basic","title":"Базовый character controller","description":"Движение, остановка, разворот, прыжок, падение и приземление в текущем Canvas runtime.","acceptance_criteria":"Функции присутствуют в APK и покрыты smoke-тестом.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:18Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:01:05Z","closed_at":"2026-08-17T21:41:12Z","close_reason":"Реализовано в текущем vertical slice.","metadata":{"stages":0,"primary_stage":0},"labels":["baseline","closed","primary-stage-0","stage-0"],"dependencies":[{"issue_id":"goon-game-character-basic","depends_on_id":"goon-game-e01","type":"parent-child","created_at":"2026-08-18T00:43:01Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"goon-game-e13","title":"EPIC-13 Optimization","description":"60 FPS target, object pooling, physics budgets и low profile. Разделы 41–44.","acceptance_criteria":"Производительность укладывается в зафиксированные бюджеты на средних и слабых устройствах.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:10Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:51Z","metadata":{"stages":"5,7","primary_stage":7},"labels":["epic","performance","primary-stage-7","stage-5","stage-7"],"dependencies":[{"issue_id":"goon-game-e13","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:56Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
+6 -4
View File
@@ -105,10 +105,12 @@ times with identical state signatures. The product owner confirmed completion of
external testing on 19 August 2026; the evidence boundary is recorded without
invented session data in `docs/STAGE5_HUMAN_PLAYTEST_RESULT.md`. Stage 5 is accepted.
Stage 6 production work has started. The first completed foundation slice adds a
deterministic character state machine with explicit transient and command states;
scope and final `81 JVM / 36 Android` evidence are in
`docs/STAGE6_CHARACTER_STATES.md`.
Stage 6 production work has started. Its first two completed foundation slices add
a deterministic character state machine and a bounded `CorpseManager` that owns
body registration, transformations, eviction, reset, save and restore. Scope and
final evidence are in `docs/STAGE6_CHARACTER_STATES.md` and
`docs/STAGE6_CORPSE_MANAGER.md`; the latest unfiltered gate is
`84 JVM / 37 Android`.
The debug APK is written to `app/build/outputs/apk/debug/app-debug.apk`. It is signed with Android's standard debug key and can be installed with:
@@ -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));
}
}
+6 -4
View File
@@ -179,10 +179,12 @@ baseline-задачи также имели явного владельца по
- принят `goon-game-milestone-production`.
Статус на 19 августа 2026: Stage 6 начат в изолированном production stream.
Первый foundation slice `goon-game-character-states` завершён: явные
Idle/Run/Turn/Jump/Fall/Land/Interact/Stunned/Death transitions, configured
timings и отдельные non-color animation poses прошли 81 JVM-тест и финальную
Android 16 matrix `36/36`. Следующий foundation slice — `CorpseManager`.
Завершены первые два foundation slice. `goon-game-character-states` добавил
явные Idle/Run/Turn/Jump/Fall/Land/Interact/Stunned/Death transitions.
`goon-game-corpse-manager` перенёс регистрацию, transforms, FIFO budget, reset,
save/restore и diagnostics тел в отдельного владельца без изменения физики и
геометрии. Последний gate: 84 JVM-теста и Android 16 matrix `37/37`.
Следующий foundation slice — event-driven `PuzzleSystem`.
### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней
+50
View File
@@ -0,0 +1,50 @@
# Stage 6 CorpseManager
Date: 19 August 2026
Beads task: `goon-game-corpse-manager`
## Contract
`CorpseManager` is the single owner of bodies in one active level. It provides:
- bounded registration with stable IDs and deterministic FIFO eviction;
- explicit remove and level-reset lifecycle operations;
- composable `PINNED`, `FROZEN`, and `CONDUCTIVE` transformations;
- body physics facts needed by collision, moving-platform, save, and restore code;
- immutable lifecycle diagnostics for automation and regression tests.
`GameView` remains the high-frequency physics adapter, but it no longer creates,
owns, evicts, resets, or restores bodies through an internal collection. The
manager exposes an unmodifiable live view, so callers can update owned body facts
without being able to change collection membership.
The body budget and FIFO policy preserve the accepted Vertical Slice behavior.
Restart and level changes clear active bodies through the manager; save/restore
re-registers bodies and then reapplies their stored transformations. No level
geometry, movement, collision constant, or death signature changed.
## TDD evidence
Separate RED cycles covered the missing manager contract, removal/reset
diagnostics, mutable physics facts plus composable transformations, rejection of
foreign bodies, and the missing `GameView` automation hook. Each RED was followed
by its focused GREEN before the next behavior was introduced.
The Android integration test creates a real level-10 saw body through production
input, verifies manager registration, restarts the level through the public game
surface, and verifies that manager reset owns the cleanup.
## Final gates
- JSON validator: `10/10`, format `1`, physics `1`.
- JVM: `84`, failures `0`, errors `0`, skipped `0`.
- `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN.
- focused Android 16 lifecycle: `1/1` GREEN.
- final unfiltered Android 16 matrix: `37/37`, failures `0`, errors `0`,
skipped `0`, `427.237 s`, on the exact final APK/test APK.
- debug APK ZIP integrity: `unzip -t` GREEN.
- debug APK SHA-256:
`982fcf312f10bf23feb3a8794b708b61002012a3706c94ebb4320fefb36fb889`.
The dedicated AVD was stopped after the final gate.