From 62ee6877f90b7b60a9ad99b5722860e37ee30c9c Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Wed, 19 Aug 2026 03:20:24 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20state=20machine=20=D0=BF=D0=B5=D1=80=D1=81=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=D0=B6=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .beads/issues.jsonl | 6 +- README.md | 5 + .../game/CharacterStateIntegrationTest.java | 70 +++++++++++ .../game/CharacterStateMachine.java | 100 ++++++++++++++++ .../com/projectsacrifice/game/GameView.java | 29 ++++- .../projectsacrifice/game/PhysicsConfig.java | 17 ++- .../projectsacrifice/game/VolunteerRig.java | 44 ++++++- .../game/CharacterStateMachineTest.java | 109 ++++++++++++++++++ .../game/PhysicsConfigTest.java | 4 + .../game/VolunteerRigTest.java | 28 +++++ docs/CURRENT_ROADMAP.md | 6 + docs/STAGE6_CHARACTER_STATES.md | 53 +++++++++ 12 files changed, 463 insertions(+), 8 deletions(-) create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/CharacterStateIntegrationTest.java create mode 100644 app/src/main/java/com/projectsacrifice/game/CharacterStateMachine.java create mode 100644 app/src/test/java/com/projectsacrifice/game/CharacterStateMachineTest.java create mode 100644 docs/STAGE6_CHARACTER_STATES.md diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 864d84a..fd81ea0 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -52,8 +52,8 @@ {"_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-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":"Система имеет отдельный контракт, тесты и не меняет решения существующих уровней.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1440,"created_at":"2026-08-17T21:39:27Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:28Z","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":"Все переходы детерминированы, визуально различимы и имеют тесты.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:39:20Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:28Z","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-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-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} {"_type":"issue","id":"goon-game-e09","title":"EPIC-09 Save \u0026 Progression","description":"Версионированные сохранения, unlock, challenges, collectibles и статистика. Разделы 20–22, 34.","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:01Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:33Z","metadata":{"stages":"0,5,6","primary_stage":6},"labels":["epic","primary-stage-6","progression","save","stage-0","stage-5","stage-6"],"dependencies":[{"issue_id":"goon-game-e09","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:51Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -61,7 +61,7 @@ {"_type":"issue","id":"goon-game-e06","title":"EPIC-06 Camera","description":"Следящая камера, look-ahead, damping, bounds и focus targets. Раздел 18.","acceptance_criteria":"Камера показывает будущий маршрут, не дёргается и соблюдает границы уровня.","notes":"Stage 5 camera acceptance complete: bounded follow/focus/shake, level bounds and reduced motion; both EPIC children are closed.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:55Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T23:03:03Z","closed_at":"2026-08-18T23:03:03Z","close_reason":"Camera foundation and production polish acceptance are complete.","metadata":{"stages":"0,5","primary_stage":5},"labels":["camera","epic","primary-stage-5","stage-0","stage-5"],"dependencies":[{"issue_id":"goon-game-e06","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:48Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e05","title":"EPIC-05 Puzzle Objects","description":"Кнопки, двери, платформы, весовые объекты и событийные сигналы. Разделы 11–14, 37.","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:38:53Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:15Z","metadata":{"stages":"0,6","primary_stage":6},"labels":["epic","primary-stage-6","puzzle","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e05","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:47Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-e04","title":"EPIC-04 Hazards","description":"Каталог опасностей и универсальный контракт источника смерти. Разделы 5, 10, 38.","acceptance_criteria":"Опасности data-driven, задокументированы и проходят Android/QA проверки.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:51Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:03:11Z","metadata":{"stages":"0,6","primary_stage":6},"labels":["epic","hazards","primary-stage-6","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e04","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:45Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"goon-game-e01","title":"EPIC-01 Character","description":"Персонаж, state machine, движение и platforming. Разделы 3, 17, 28, 36.","acceptance_criteria":"Персонаж соответствует утверждённому набору состояний и стабильно управляется на Android.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:45Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:56Z","metadata":{"stages":"0,6","primary_stage":6},"labels":["character","epic","primary-stage-6","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e01","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:42Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"goon-game-e01","title":"EPIC-01 Character","description":"Персонаж, state machine, движение и platforming. Разделы 3, 17, 28, 36.","acceptance_criteria":"Персонаж соответствует утверждённому набору состояний и стабильно управляется на Android.","notes":"2026-08-19: baseline controller and production state-machine children are closed; exact Android 16 36/36 confirms stable controls and routes.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:45Z","created_by":"E.Gavrilov","updated_at":"2026-08-19T00:19:58Z","closed_at":"2026-08-19T00:19:58Z","close_reason":"Character controller and complete production animation-state contract are implemented and Android-verified.","metadata":{"stages":"0,6","primary_stage":6},"labels":["character","epic","primary-stage-6","stage-0","stage-6"],"dependencies":[{"issue_id":"goon-game-e01","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:42Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-milestone-alpha","title":"Milestone: Alpha","description":"Вся игра проходима от первого меню до финала; контент существует.","acceptance_criteria":"End-to-end прохождение завершено на release-like build.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:05Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:39Z","metadata":{"stages":8,"primary_stage":8},"labels":["alpha","milestone","primary-stage-8","stage-8"],"dependencies":[{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-crash-reporting","type":"blocks","created_at":"2026-08-18T00:44:28Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-device-matrix","type":"blocks","created_at":"2026-08-18T00:44:27Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:57Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-alpha","depends_on_id":"goon-game-milestone-production","type":"blocks","created_at":"2026-08-18T00:44:26Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"goon-game-milestone-production","title":"Milestone: Production","description":"Массовое производство уровней параллельными потоками после утверждения vertical slice.","acceptance_criteria":"Полный content matrix реализован и прошёл content pipeline.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:41:02Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:25Z","metadata":{"stages":6,"primary_stage":6},"labels":["milestone","primary-stage-6","production","stage-6"],"dependencies":[{"issue_id":"goon-game-milestone-production","depends_on_id":"goon-game-content-worlds","type":"blocks","created_at":"2026-08-18T00:44:25Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-production","depends_on_id":"goon-game-e16","type":"parent-child","created_at":"2026-08-18T00:43:56Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-milestone-production","depends_on_id":"goon-game-milestone-vslice","type":"blocks","created_at":"2026-08-18T00:44:24Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"goon-game-analytics-contract","title":"Схема gameplay analytics","description":"Версионировать level_start/restart/death/complete/abandon/challenge и heatmap payload.","acceptance_criteria":"События документированы, минимальны и проверены на тестовом backend.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":2,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:36Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T21:02:35Z","metadata":{"stages":7,"primary_stage":7},"labels":["analytics","primary-stage-7","production","stage-7"],"dependencies":[{"issue_id":"goon-game-analytics-contract","depends_on_id":"goon-game-e14","type":"parent-child","created_at":"2026-08-18T00:43:42Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} diff --git a/README.md b/README.md index 8543eb3..73454b8 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,11 @@ 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`. + 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: ```sh diff --git a/app/src/androidTest/java/com/projectsacrifice/game/CharacterStateIntegrationTest.java b/app/src/androidTest/java/com/projectsacrifice/game/CharacterStateIntegrationTest.java new file mode 100644 index 0000000..db20f8d --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/CharacterStateIntegrationTest.java @@ -0,0 +1,70 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; + +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 CharacterStateIntegrationTest { + @Test + public void gameViewPublishesMovementAndCommandStates() throws Exception { + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + try (ActivityScenario activity = + ActivityScenario.launch(MainActivity.class)) { + GameView[] view = new GameView[1]; + activity.onActivity(current -> view[0] = current.gameViewForAutomation()); + instrumentation.runOnMainSync(() -> view[0].startLevelForAutomation(1)); + + awaitState(instrumentation, view[0], CharacterStateMachine.State.IDLE, 3_000L); + instrumentation.runOnMainSync( + () -> view[0].setAutomationInput(false, true, false)); + awaitState(instrumentation, view[0], CharacterStateMachine.State.RUN, 3_000L); + + instrumentation.runOnMainSync( + () -> view[0].setAutomationInput(true, false, false)); + awaitState(instrumentation, view[0], CharacterStateMachine.State.TURN, 3_000L); + instrumentation.runOnMainSync( + () -> view[0].setAutomationInput(false, true, true)); + awaitState(instrumentation, view[0], CharacterStateMachine.State.JUMP, 3_000L); + instrumentation.runOnMainSync( + () -> view[0].setAutomationInput(false, true, false)); + awaitState(instrumentation, view[0], CharacterStateMachine.State.FALL, 3_000L); + awaitState(instrumentation, view[0], CharacterStateMachine.State.LAND, 3_000L); + + instrumentation.runOnMainSync(view[0]::triggerInteractionForAutomation); + assertEquals(CharacterStateMachine.State.INTERACT, + state(instrumentation, view[0])); + instrumentation.runOnMainSync(view[0]::triggerStunForAutomation); + assertEquals(CharacterStateMachine.State.STUNNED, + state(instrumentation, view[0])); + } + } + + private static void awaitState(Instrumentation instrumentation, GameView view, + CharacterStateMachine.State expected, + long timeoutMillis) throws Exception { + long deadline = System.currentTimeMillis() + timeoutMillis; + CharacterStateMachine.State current = state(instrumentation, view); + while (System.currentTimeMillis() < deadline) { + current = state(instrumentation, view); + if (current == expected) return; + Thread.sleep(24L); + } + throw new AssertionError("Expected " + expected + " but was " + current); + } + + private static CharacterStateMachine.State state( + Instrumentation instrumentation, GameView view) { + CharacterStateMachine.State[] result = new CharacterStateMachine.State[1]; + instrumentation.runOnMainSync( + () -> result[0] = view.characterStateForAutomation()); + return result[0]; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/CharacterStateMachine.java b/app/src/main/java/com/projectsacrifice/game/CharacterStateMachine.java new file mode 100644 index 0000000..8782482 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/CharacterStateMachine.java @@ -0,0 +1,100 @@ +package com.projectsacrifice.game; + +/** Deterministic animation state derived from the character's public physics facts. */ +public final class CharacterStateMachine { + public enum State { + IDLE, + RUN, + JUMP, + FALL, + TURN, + LAND, + INTERACT, + STUNNED, + DEATH + } + + private final PhysicsConfig physics; + private State state = State.IDLE; + private float transientSeconds; + private int facing = 1; + private boolean wasOnGround; + + public CharacterStateMachine(PhysicsConfig physics) { + if (physics == null) throw new IllegalArgumentException("physics is required"); + this.physics = physics; + } + + public void reset(boolean alive, boolean onGround, float velocityX, + float velocityY, int facing) { + state = baseState(alive, onGround, velocityX, velocityY); + transientSeconds = 0f; + this.facing = normalizeFacing(facing); + wasOnGround = onGround; + } + + public void observe(float deltaSeconds, boolean alive, boolean onGround, + float velocityX, float velocityY, int facing) { + float safeDelta = Math.max(0f, deltaSeconds); + int normalizedFacing = normalizeFacing(facing); + if (!alive) { + state = State.DEATH; + transientSeconds = 0f; + this.facing = normalizedFacing; + wasOnGround = onGround; + return; + } + if ((state == State.TURN || state == State.LAND || state == State.INTERACT + || state == State.STUNNED) + && transientSeconds > 0f) { + transientSeconds = Math.max(0f, transientSeconds - safeDelta); + this.facing = normalizedFacing; + wasOnGround = onGround; + if (transientSeconds > 0f && onGround) return; + } + if (onGround && !wasOnGround) { + state = State.LAND; + transientSeconds = physics.landSeconds(); + this.facing = normalizedFacing; + wasOnGround = true; + return; + } + if (onGround && normalizedFacing != this.facing) { + state = State.TURN; + transientSeconds = physics.turnSeconds(); + this.facing = normalizedFacing; + wasOnGround = true; + return; + } + this.facing = normalizedFacing; + wasOnGround = onGround; + state = baseState(alive, onGround, velocityX, velocityY); + } + + public State state() { + return state; + } + + public void beginInteraction() { + if (state == State.DEATH || state == State.STUNNED) return; + state = State.INTERACT; + transientSeconds = physics.interactSeconds(); + } + + public void stun() { + if (state == State.DEATH) return; + state = State.STUNNED; + transientSeconds = physics.stunnedSeconds(); + } + + private static State baseState(boolean alive, boolean onGround, + float velocityX, float velocityY) { + if (!alive) return State.DEATH; + if (!onGround) return velocityY < 0f ? State.JUMP : State.FALL; + return Math.abs(velocityX) >= 30f ? State.RUN : State.IDLE; + } + + private static int normalizeFacing(int value) { + return value < 0 ? -1 : 1; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index f1c890b..88d893d 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -233,6 +233,8 @@ public final class GameView extends View implements Runnable { private final ProceduralAudioEngine audioEngine; private final SparseArray pointers = new SparseArray<>(); private final Player player = new Player(); + private final CharacterStateMachine characterStateMachine = + new CharacterStateMachine(PHYSICS); private final ArrayList corpses = new ArrayList<>(); private final ArrayList platformStates = new ArrayList<>(); private final Map toggleSignals = new HashMap<>(); @@ -427,6 +429,8 @@ public final class GameView extends View implements Runnable { player.jumpBuffer = 0f; player.supportIndex = -1; player.facing = 1; + characterStateMachine.reset(player.alive, player.onGround, + player.vx, player.vy, player.facing); if (deaths > 0) audioEngine.play(AudioCue.RESPAWN_VOICE); } @@ -496,6 +500,10 @@ public final class GameView extends View implements Runnable { emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); } if (contact && !previous) audioEngine.play(AudioCue.SWITCH); + if (contact && !previous && player.alive + && intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, item.box)) { + characterStateMachine.beginInteraction(); + } previousSwitchContact.put(contactKey, contact); } } @@ -594,6 +602,8 @@ public final class GameView extends View implements Runnable { movePlayerVertical(player.vy * dt); player.x = GameMath.clamp(player.x, 0f, level.worldWidth - PLAYER_WIDTH); if (player.y > VIEW_HEIGHT + 180f) kill(DeathType.PIT, null); + characterStateMachine.observe(dt, player.alive, player.onGround, + player.vx, player.vy, player.facing); } private void movePlayerHorizontal(float amount) { @@ -757,6 +767,8 @@ public final class GameView extends View implements Runnable { player.alive = false; player.vx = player.vy = 0f; player.supportIndex = -1; + characterStateMachine.observe(0f, false, player.onGround, + player.vx, player.vy, player.facing); deaths++; respawnTimer = RESPAWN_SECONDS; emitHaptic(Build.VERSION.SDK_INT >= 27 @@ -893,8 +905,7 @@ public final class GameView extends View implements Runnable { for (Corpse corpse : corpses) drawCorpse(canvas, corpse); VolunteerRig.Skin skin = VolunteerRig.Skin.forLevel(level.number); if (player.alive) { - VolunteerRig.Motion motion = VolunteerRig.aliveMotion( - player.onGround, player.vx, player.vy); + VolunteerRig.Motion motion = VolunteerRig.motionFor(characterStateMachine.state()); volunteerRenderer.draw(canvas, paint, stroke, path, player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, skin, motion, volunteerPhase(motion), player.facing, 1f, @@ -2339,6 +2350,8 @@ public final class GameView extends View implements Runnable { player.jumpBuffer = savedPlayer.jumpBufferSeconds(); player.supportIndex = savedPlayer.supportIndex(); player.facing = savedPlayer.facing(); + characterStateMachine.reset(player.alive, player.onGround, + player.vx, player.vy, player.facing); corpses.clear(); for (GameSessionSnapshot.BodyData savedBody : snapshot.bodies()) { @@ -2427,4 +2440,16 @@ public final class GameView extends View implements Runnable { player.vy ); } + + public CharacterStateMachine.State characterStateForAutomation() { + return characterStateMachine.state(); + } + + public void triggerInteractionForAutomation() { + characterStateMachine.beginInteraction(); + } + + public void triggerStunForAutomation() { + characterStateMachine.stun(); + } } diff --git a/app/src/main/java/com/projectsacrifice/game/PhysicsConfig.java b/app/src/main/java/com/projectsacrifice/game/PhysicsConfig.java index 71067cf..b5e25f8 100644 --- a/app/src/main/java/com/projectsacrifice/game/PhysicsConfig.java +++ b/app/src/main/java/com/projectsacrifice/game/PhysicsConfig.java @@ -4,7 +4,7 @@ package com.projectsacrifice.game; public final class PhysicsConfig { private static final PhysicsConfig RELEASE = new PhysicsConfig( 1, 1f / 60f, 1_800f, 370f, 880f, - 0.13f, 0.14f, 0.78f, 16); + 0.13f, 0.14f, 0.78f, 0.10f, 0.12f, 0.24f, 0.32f, 16); private final int version; private final float fixedStepSeconds; @@ -14,12 +14,17 @@ public final class PhysicsConfig { private final float coyoteSeconds; private final float jumpBufferSeconds; private final float respawnSeconds; + private final float turnSeconds; + private final float landSeconds; + private final float interactSeconds; + private final float stunnedSeconds; private final int maximumBodies; private PhysicsConfig(int version, float fixedStepSeconds, float gravity, float runSpeed, float jumpSpeed, float coyoteSeconds, float jumpBufferSeconds, float respawnSeconds, - int maximumBodies) { + float turnSeconds, float landSeconds, float interactSeconds, + float stunnedSeconds, int maximumBodies) { this.version = version; this.fixedStepSeconds = fixedStepSeconds; this.gravity = gravity; @@ -28,6 +33,10 @@ public final class PhysicsConfig { this.coyoteSeconds = coyoteSeconds; this.jumpBufferSeconds = jumpBufferSeconds; this.respawnSeconds = respawnSeconds; + this.turnSeconds = turnSeconds; + this.landSeconds = landSeconds; + this.interactSeconds = interactSeconds; + this.stunnedSeconds = stunnedSeconds; this.maximumBodies = maximumBodies; } @@ -40,5 +49,9 @@ public final class PhysicsConfig { public float coyoteSeconds() { return coyoteSeconds; } public float jumpBufferSeconds() { return jumpBufferSeconds; } public float respawnSeconds() { return respawnSeconds; } + public float turnSeconds() { return turnSeconds; } + public float landSeconds() { return landSeconds; } + public float interactSeconds() { return interactSeconds; } + public float stunnedSeconds() { return stunnedSeconds; } public int maximumBodies() { return maximumBodies; } } diff --git a/app/src/main/java/com/projectsacrifice/game/VolunteerRig.java b/app/src/main/java/com/projectsacrifice/game/VolunteerRig.java index 8150b2d..5809597 100644 --- a/app/src/main/java/com/projectsacrifice/game/VolunteerRig.java +++ b/app/src/main/java/com/projectsacrifice/game/VolunteerRig.java @@ -49,8 +49,13 @@ public final class VolunteerRig { public enum Motion { IDLE, RUN, + TURN, JUMP, FALL, + LAND, + INTERACT, + STUNNED, + DEATH, DEAD, PINNED, FROZEN, @@ -117,6 +122,12 @@ public final class VolunteerRig { return Math.abs(horizontalVelocity) >= 30f ? Motion.RUN : Motion.IDLE; } + public static Motion motionFor(CharacterStateMachine.State state) { + CharacterStateMachine.State safeState = state == null + ? CharacterStateMachine.State.IDLE : state; + return Motion.valueOf(safeState.name()); + } + public static Motion bodyMotion(boolean frozen, boolean conductive, boolean pinned) { if (frozen) return Motion.FROZEN; if (conductive) return Motion.CONDUCTIVE; @@ -156,6 +167,14 @@ public final class VolunteerRig { xs[Joint.RIGHT_FOOT.ordinal()] += swing * 0.18f; ys[Joint.ROOT.ordinal()] -= Math.abs(swing) * 0.025f; break; + case TURN: + xs[Joint.LEFT_HAND.ordinal()] = 0.72f; + xs[Joint.RIGHT_HAND.ordinal()] = 0.28f; + ys[Joint.LEFT_HAND.ordinal()] = 0.55f; + ys[Joint.RIGHT_HAND.ordinal()] = 0.55f; + xs[Joint.LEFT_KNEE.ordinal()] = 0.48f; + xs[Joint.RIGHT_KNEE.ordinal()] = 0.52f; + break; case JUMP: xs[Joint.LEFT_HAND.ordinal()] = 0.16f; xs[Joint.RIGHT_HAND.ordinal()] = 0.84f; @@ -174,6 +193,28 @@ public final class VolunteerRig { xs[Joint.LEFT_FOOT.ordinal()] = 0.23f; xs[Joint.RIGHT_FOOT.ordinal()] = 0.77f; break; + case LAND: + ys[Joint.ROOT.ordinal()] = 0.91f; + ys[Joint.HIPS.ordinal()] = 0.74f; + ys[Joint.LEFT_KNEE.ordinal()] = 0.87f; + ys[Joint.RIGHT_KNEE.ordinal()] = 0.87f; + xs[Joint.LEFT_HAND.ordinal()] = 0.18f; + xs[Joint.RIGHT_HAND.ordinal()] = 0.82f; + break; + case INTERACT: + xs[Joint.RIGHT_HAND.ordinal()] = 0.98f; + ys[Joint.RIGHT_HAND.ordinal()] = 0.47f; + xs[Joint.LEFT_HAND.ordinal()] = 0.45f; + ys[Joint.LEFT_HAND.ordinal()] = 0.54f; + break; + case STUNNED: + xs[Joint.LEFT_HAND.ordinal()] = 0.36f; + xs[Joint.RIGHT_HAND.ordinal()] = 0.64f; + ys[Joint.LEFT_HAND.ordinal()] = 0.22f; + ys[Joint.RIGHT_HAND.ordinal()] = 0.22f; + xs[Joint.HEAD.ordinal()] += swing * 0.04f; + break; + case DEATH: case DEAD: case PINNED: case FROZEN: @@ -196,7 +237,8 @@ public final class VolunteerRig { if (facing < 0) { for (int i = 0; i < xs.length; i++) xs[i] = 1f - xs[i]; } - boolean visorLit = safeMotion != Motion.DEAD + boolean visorLit = safeMotion != Motion.DEATH + && safeMotion != Motion.DEAD && safeMotion != Motion.PINNED && safeMotion != Motion.FROZEN && safeMotion != Motion.CONDUCTIVE; diff --git a/app/src/test/java/com/projectsacrifice/game/CharacterStateMachineTest.java b/app/src/test/java/com/projectsacrifice/game/CharacterStateMachineTest.java new file mode 100644 index 0000000..a5796db --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/CharacterStateMachineTest.java @@ -0,0 +1,109 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public final class CharacterStateMachineTest { + @Test + public void groundedVelocitySelectsIdleAndRunDeterministically() { + CharacterStateMachine machine = new CharacterStateMachine(PhysicsConfig.release()); + + machine.reset(true, true, 0f, 0f, 1); + assertEquals(CharacterStateMachine.State.IDLE, machine.state()); + + machine.observe(1f / 60f, true, true, 370f, 0f, 1); + assertEquals(CharacterStateMachine.State.RUN, machine.state()); + + machine.observe(1f / 60f, true, true, 0f, 0f, 1); + assertEquals(CharacterStateMachine.State.IDLE, machine.state()); + } + + @Test + public void verticalMotionSelectsJumpFallAndDeathHasPriority() { + CharacterStateMachine machine = new CharacterStateMachine(PhysicsConfig.release()); + + machine.reset(true, false, 120f, -300f, 1); + assertEquals(CharacterStateMachine.State.JUMP, machine.state()); + + machine.observe(1f / 60f, true, false, 120f, 80f, 1); + assertEquals(CharacterStateMachine.State.FALL, machine.state()); + + machine.observe(1f / 60f, false, true, 0f, 0f, 1); + assertEquals(CharacterStateMachine.State.DEATH, machine.state()); + } + + @Test + public void changingFacingEmitsOneConfiguredTurnWindow() { + PhysicsConfig physics = PhysicsConfig.release(); + CharacterStateMachine machine = new CharacterStateMachine(physics); + machine.reset(true, true, physics.runSpeed(), 0f, 1); + + machine.observe(physics.fixedStepSeconds(), true, true, + -physics.runSpeed(), 0f, -1); + assertEquals(CharacterStateMachine.State.TURN, machine.state()); + + machine.observe(physics.turnSeconds() + physics.fixedStepSeconds(), + true, true, -physics.runSpeed(), 0f, -1); + assertEquals(CharacterStateMachine.State.RUN, machine.state()); + } + + @Test + public void firstGroundedFrameAfterFallingEmitsConfiguredLandWindow() { + PhysicsConfig physics = PhysicsConfig.release(); + CharacterStateMachine machine = new CharacterStateMachine(physics); + machine.reset(true, false, 0f, 300f, 1); + + machine.observe(physics.fixedStepSeconds(), true, true, 0f, 0f, 1); + assertEquals(CharacterStateMachine.State.LAND, machine.state()); + + machine.observe(physics.landSeconds() + physics.fixedStepSeconds(), + true, true, 0f, 0f, 1); + assertEquals(CharacterStateMachine.State.IDLE, machine.state()); + } + + @Test + public void interactionIsAConfiguredCommandAndDeathCancelsIt() { + PhysicsConfig physics = PhysicsConfig.release(); + CharacterStateMachine machine = new CharacterStateMachine(physics); + machine.reset(true, true, 0f, 0f, 1); + + machine.beginInteraction(); + assertEquals(CharacterStateMachine.State.INTERACT, machine.state()); + machine.observe(physics.interactSeconds() * 0.5f, + true, true, 0f, 0f, 1); + assertEquals(CharacterStateMachine.State.INTERACT, machine.state()); + + machine.observe(physics.fixedStepSeconds(), false, true, 0f, 0f, 1); + assertEquals(CharacterStateMachine.State.DEATH, machine.state()); + } + + @Test + public void stunOverridesInteractionThenReturnsToObservedMovement() { + PhysicsConfig physics = PhysicsConfig.release(); + CharacterStateMachine machine = new CharacterStateMachine(physics); + machine.reset(true, true, physics.runSpeed(), 0f, 1); + machine.beginInteraction(); + + machine.stun(); + assertEquals(CharacterStateMachine.State.STUNNED, machine.state()); + machine.observe(physics.stunnedSeconds() * 0.5f, + true, true, physics.runSpeed(), 0f, 1); + assertEquals(CharacterStateMachine.State.STUNNED, machine.state()); + + machine.observe(physics.stunnedSeconds(), + true, true, physics.runSpeed(), 0f, 1); + assertEquals(CharacterStateMachine.State.RUN, machine.state()); + } + + @Test + public void interactionCannotDowngradeAnActiveStun() { + CharacterStateMachine machine = new CharacterStateMachine(PhysicsConfig.release()); + machine.reset(true, true, 0f, 0f, 1); + + machine.stun(); + machine.beginInteraction(); + + assertEquals(CharacterStateMachine.State.STUNNED, machine.state()); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/PhysicsConfigTest.java b/app/src/test/java/com/projectsacrifice/game/PhysicsConfigTest.java index 80f774d..6b05f6f 100644 --- a/app/src/test/java/com/projectsacrifice/game/PhysicsConfigTest.java +++ b/app/src/test/java/com/projectsacrifice/game/PhysicsConfigTest.java @@ -18,6 +18,10 @@ public final class PhysicsConfigTest { assertEquals(0.13f, physics.coyoteSeconds(), 0f); assertEquals(0.14f, physics.jumpBufferSeconds(), 0f); assertEquals(0.78f, physics.respawnSeconds(), 0f); + assertEquals(0.10f, physics.turnSeconds(), 0f); + assertEquals(0.12f, physics.landSeconds(), 0f); + assertEquals(0.24f, physics.interactSeconds(), 0f); + assertEquals(0.32f, physics.stunnedSeconds(), 0f); assertTrue(physics.maximumBodies() >= 10); } } diff --git a/app/src/test/java/com/projectsacrifice/game/VolunteerRigTest.java b/app/src/test/java/com/projectsacrifice/game/VolunteerRigTest.java index ae1014d..f954f03 100644 --- a/app/src/test/java/com/projectsacrifice/game/VolunteerRigTest.java +++ b/app/src/test/java/com/projectsacrifice/game/VolunteerRigTest.java @@ -84,4 +84,32 @@ public final class VolunteerRigTest { assertEquals(VolunteerRig.Motion.DEAD, VolunteerRig.bodyMotion(false, false, false)); } + + @Test + public void everyLivingCharacterStateHasAnExplicitAnimationMotion() { + for (CharacterStateMachine.State state : CharacterStateMachine.State.values()) { + assertEquals(state.name(), VolunteerRig.motionFor(state).name()); + } + } + + @Test + public void transientCharacterStatesHaveNonColorPoseSignals() { + VolunteerRig.Skin skin = VolunteerRig.Skin.BRASS_SCOUT; + VolunteerRig.Pose idle = VolunteerRig.pose(skin, VolunteerRig.Motion.IDLE, 0.25f, 1); + VolunteerRig.Pose turn = VolunteerRig.pose(skin, VolunteerRig.Motion.TURN, 0.25f, 1); + VolunteerRig.Pose land = VolunteerRig.pose(skin, VolunteerRig.Motion.LAND, 0.25f, 1); + VolunteerRig.Pose interact = VolunteerRig.pose( + skin, VolunteerRig.Motion.INTERACT, 0.25f, 1); + VolunteerRig.Pose stunned = VolunteerRig.pose( + skin, VolunteerRig.Motion.STUNNED, 0.25f, 1); + + assertNotEquals(idle.x(VolunteerRig.Joint.LEFT_HAND), + turn.x(VolunteerRig.Joint.LEFT_HAND), 0.001f); + assertNotEquals(idle.y(VolunteerRig.Joint.ROOT), + land.y(VolunteerRig.Joint.ROOT), 0.001f); + assertNotEquals(idle.x(VolunteerRig.Joint.RIGHT_HAND), + interact.x(VolunteerRig.Joint.RIGHT_HAND), 0.001f); + assertNotEquals(idle.y(VolunteerRig.Joint.LEFT_HAND), + stunned.y(VolunteerRig.Joint.LEFT_HAND), 0.001f); + } } diff --git a/docs/CURRENT_ROADMAP.md b/docs/CURRENT_ROADMAP.md index f6f8d36..dec056b 100644 --- a/docs/CURRENT_ROADMAP.md +++ b/docs/CURRENT_ROADMAP.md @@ -178,6 +178,12 @@ baseline-задачи также имели явного владельца по - content не содержит blocker-softlocks и обязательных невосстановимых объектов; - принят `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`. + ### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней Задачи: diff --git a/docs/STAGE6_CHARACTER_STATES.md b/docs/STAGE6_CHARACTER_STATES.md new file mode 100644 index 0000000..908224a --- /dev/null +++ b/docs/STAGE6_CHARACTER_STATES.md @@ -0,0 +1,53 @@ +# Stage 6 character state machine + +Date: 19 August 2026 + +Beads task: `goon-game-character-states` + +## Contract + +`CharacterStateMachine` is a deterministic public model driven by the physics +facts already owned by `GameView`: alive, grounded, velocity and facing. It +publishes: + +- `IDLE`, `RUN`, `JUMP`, `FALL`, `DEATH` base states; +- configured transient `TURN` and `LAND` states; +- configured command states `INTERACT` and `STUNNED`; +- priority `DEATH > STUNNED > INTERACT > locomotion`. + +The model does not own collision or movement and therefore does not change the +accepted Stage 5 physics. Switch contact drives the production `INTERACT` +transition. `STUNNED` has a public command and a tested animation contract; its +production hazard source belongs to the Stage 6 hazards work. + +`PhysicsConfig` remains the single source for movement, coyote/input buffer, +respawn and the new turn/land/interact/stunned durations. The config/level +physics version remains `1` because no movement or collision constant changed. + +## Visual contract + +Every living state maps explicitly to a `VolunteerRig.Motion`. `TURN`, `LAND`, +`INTERACT` and `STUNNED` change skeleton joints, so the states remain distinct +without relying on color. Collision dimensions stay `62 x 100`. + +## TDD evidence + +Separate RED cycles covered the missing state model, airborne/death states, +configured turn, configured land, interaction, stun priority, visual mapping, +GameView automation integration and the regression where interaction could +downgrade an active stun. Each RED was followed by its focused GREEN before the +next behavior was introduced. + +## Final gates + +- JSON validator: `10/10`, format `1`, physics `1`. +- JVM: `81`, failures `0`, errors `0`. +- `lintDebug`, `assembleDebug`, `assembleDebugAndroidTest`: GREEN. +- focused Android 16 state integration: `1/1` GREEN. +- final unfiltered Android 16 matrix: `36/36`, failures `0`, errors `0`, + `720.578 s`, on the exact final APK/test APK. +- debug APK ZIP integrity: `unzip -t` GREEN. +- debug APK SHA-256: + `d25b7dd0a06cead0d514539773acd0a84ed92bf7c2510b9b880ded2f66508581`. + +The AVD was stopped after the final gate.