Завершить state machine персонажа

This commit is contained in:
2026-08-19 03:20:24 +03:00
parent 0943caa3cc
commit 62ee6877f9
12 changed files with 463 additions and 8 deletions
+3 -3
View File
@@ -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-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-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-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-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":"Все переходы детерминированы, визуально различимы и имеют тесты.","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-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-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-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} {"_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-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-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-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-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-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} {"_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}
+5
View File
@@ -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 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. 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: 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 ```sh
@@ -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<MainActivity> 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];
}
}
@@ -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;
}
}
@@ -233,6 +233,8 @@ public final class GameView extends View implements Runnable {
private final ProceduralAudioEngine audioEngine; private final ProceduralAudioEngine audioEngine;
private final SparseArray<PointF> pointers = new SparseArray<>(); private final SparseArray<PointF> pointers = new SparseArray<>();
private final Player player = new Player(); private final Player player = new Player();
private final CharacterStateMachine characterStateMachine =
new CharacterStateMachine(PHYSICS);
private final ArrayList<Corpse> corpses = new ArrayList<>(); private final ArrayList<Corpse> corpses = new ArrayList<>();
private final ArrayList<PlatformState> platformStates = new ArrayList<>(); private final ArrayList<PlatformState> platformStates = new ArrayList<>();
private final Map<String, Boolean> toggleSignals = new HashMap<>(); private final Map<String, Boolean> toggleSignals = new HashMap<>();
@@ -427,6 +429,8 @@ public final class GameView extends View implements Runnable {
player.jumpBuffer = 0f; player.jumpBuffer = 0f;
player.supportIndex = -1; player.supportIndex = -1;
player.facing = 1; player.facing = 1;
characterStateMachine.reset(player.alive, player.onGround,
player.vx, player.vy, player.facing);
if (deaths > 0) audioEngine.play(AudioCue.RESPAWN_VOICE); if (deaths > 0) audioEngine.play(AudioCue.RESPAWN_VOICE);
} }
@@ -496,6 +500,10 @@ public final class GameView extends View implements Runnable {
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
} }
if (contact && !previous) audioEngine.play(AudioCue.SWITCH); 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); previousSwitchContact.put(contactKey, contact);
} }
} }
@@ -594,6 +602,8 @@ public final class GameView extends View implements Runnable {
movePlayerVertical(player.vy * dt); movePlayerVertical(player.vy * dt);
player.x = GameMath.clamp(player.x, 0f, level.worldWidth - PLAYER_WIDTH); player.x = GameMath.clamp(player.x, 0f, level.worldWidth - PLAYER_WIDTH);
if (player.y > VIEW_HEIGHT + 180f) kill(DeathType.PIT, null); 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) { private void movePlayerHorizontal(float amount) {
@@ -757,6 +767,8 @@ public final class GameView extends View implements Runnable {
player.alive = false; player.alive = false;
player.vx = player.vy = 0f; player.vx = player.vy = 0f;
player.supportIndex = -1; player.supportIndex = -1;
characterStateMachine.observe(0f, false, player.onGround,
player.vx, player.vy, player.facing);
deaths++; deaths++;
respawnTimer = RESPAWN_SECONDS; respawnTimer = RESPAWN_SECONDS;
emitHaptic(Build.VERSION.SDK_INT >= 27 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); for (Corpse corpse : corpses) drawCorpse(canvas, corpse);
VolunteerRig.Skin skin = VolunteerRig.Skin.forLevel(level.number); VolunteerRig.Skin skin = VolunteerRig.Skin.forLevel(level.number);
if (player.alive) { if (player.alive) {
VolunteerRig.Motion motion = VolunteerRig.aliveMotion( VolunteerRig.Motion motion = VolunteerRig.motionFor(characterStateMachine.state());
player.onGround, player.vx, player.vy);
volunteerRenderer.draw(canvas, paint, stroke, path, volunteerRenderer.draw(canvas, paint, stroke, path,
player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT,
skin, motion, volunteerPhase(motion), player.facing, 1f, skin, motion, volunteerPhase(motion), player.facing, 1f,
@@ -2339,6 +2350,8 @@ public final class GameView extends View implements Runnable {
player.jumpBuffer = savedPlayer.jumpBufferSeconds(); player.jumpBuffer = savedPlayer.jumpBufferSeconds();
player.supportIndex = savedPlayer.supportIndex(); player.supportIndex = savedPlayer.supportIndex();
player.facing = savedPlayer.facing(); player.facing = savedPlayer.facing();
characterStateMachine.reset(player.alive, player.onGround,
player.vx, player.vy, player.facing);
corpses.clear(); corpses.clear();
for (GameSessionSnapshot.BodyData savedBody : snapshot.bodies()) { for (GameSessionSnapshot.BodyData savedBody : snapshot.bodies()) {
@@ -2427,4 +2440,16 @@ public final class GameView extends View implements Runnable {
player.vy player.vy
); );
} }
public CharacterStateMachine.State characterStateForAutomation() {
return characterStateMachine.state();
}
public void triggerInteractionForAutomation() {
characterStateMachine.beginInteraction();
}
public void triggerStunForAutomation() {
characterStateMachine.stun();
}
} }
@@ -4,7 +4,7 @@ package com.projectsacrifice.game;
public final class PhysicsConfig { public final class PhysicsConfig {
private static final PhysicsConfig RELEASE = new PhysicsConfig( private static final PhysicsConfig RELEASE = new PhysicsConfig(
1, 1f / 60f, 1_800f, 370f, 880f, 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 int version;
private final float fixedStepSeconds; private final float fixedStepSeconds;
@@ -14,12 +14,17 @@ public final class PhysicsConfig {
private final float coyoteSeconds; private final float coyoteSeconds;
private final float jumpBufferSeconds; private final float jumpBufferSeconds;
private final float respawnSeconds; 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 final int maximumBodies;
private PhysicsConfig(int version, float fixedStepSeconds, float gravity, private PhysicsConfig(int version, float fixedStepSeconds, float gravity,
float runSpeed, float jumpSpeed, float coyoteSeconds, float runSpeed, float jumpSpeed, float coyoteSeconds,
float jumpBufferSeconds, float respawnSeconds, float jumpBufferSeconds, float respawnSeconds,
int maximumBodies) { float turnSeconds, float landSeconds, float interactSeconds,
float stunnedSeconds, int maximumBodies) {
this.version = version; this.version = version;
this.fixedStepSeconds = fixedStepSeconds; this.fixedStepSeconds = fixedStepSeconds;
this.gravity = gravity; this.gravity = gravity;
@@ -28,6 +33,10 @@ public final class PhysicsConfig {
this.coyoteSeconds = coyoteSeconds; this.coyoteSeconds = coyoteSeconds;
this.jumpBufferSeconds = jumpBufferSeconds; this.jumpBufferSeconds = jumpBufferSeconds;
this.respawnSeconds = respawnSeconds; this.respawnSeconds = respawnSeconds;
this.turnSeconds = turnSeconds;
this.landSeconds = landSeconds;
this.interactSeconds = interactSeconds;
this.stunnedSeconds = stunnedSeconds;
this.maximumBodies = maximumBodies; this.maximumBodies = maximumBodies;
} }
@@ -40,5 +49,9 @@ public final class PhysicsConfig {
public float coyoteSeconds() { return coyoteSeconds; } public float coyoteSeconds() { return coyoteSeconds; }
public float jumpBufferSeconds() { return jumpBufferSeconds; } public float jumpBufferSeconds() { return jumpBufferSeconds; }
public float respawnSeconds() { return respawnSeconds; } 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; } public int maximumBodies() { return maximumBodies; }
} }
@@ -49,8 +49,13 @@ public final class VolunteerRig {
public enum Motion { public enum Motion {
IDLE, IDLE,
RUN, RUN,
TURN,
JUMP, JUMP,
FALL, FALL,
LAND,
INTERACT,
STUNNED,
DEATH,
DEAD, DEAD,
PINNED, PINNED,
FROZEN, FROZEN,
@@ -117,6 +122,12 @@ public final class VolunteerRig {
return Math.abs(horizontalVelocity) >= 30f ? Motion.RUN : Motion.IDLE; 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) { public static Motion bodyMotion(boolean frozen, boolean conductive, boolean pinned) {
if (frozen) return Motion.FROZEN; if (frozen) return Motion.FROZEN;
if (conductive) return Motion.CONDUCTIVE; if (conductive) return Motion.CONDUCTIVE;
@@ -156,6 +167,14 @@ public final class VolunteerRig {
xs[Joint.RIGHT_FOOT.ordinal()] += swing * 0.18f; xs[Joint.RIGHT_FOOT.ordinal()] += swing * 0.18f;
ys[Joint.ROOT.ordinal()] -= Math.abs(swing) * 0.025f; ys[Joint.ROOT.ordinal()] -= Math.abs(swing) * 0.025f;
break; 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: case JUMP:
xs[Joint.LEFT_HAND.ordinal()] = 0.16f; xs[Joint.LEFT_HAND.ordinal()] = 0.16f;
xs[Joint.RIGHT_HAND.ordinal()] = 0.84f; xs[Joint.RIGHT_HAND.ordinal()] = 0.84f;
@@ -174,6 +193,28 @@ public final class VolunteerRig {
xs[Joint.LEFT_FOOT.ordinal()] = 0.23f; xs[Joint.LEFT_FOOT.ordinal()] = 0.23f;
xs[Joint.RIGHT_FOOT.ordinal()] = 0.77f; xs[Joint.RIGHT_FOOT.ordinal()] = 0.77f;
break; 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 DEAD:
case PINNED: case PINNED:
case FROZEN: case FROZEN:
@@ -196,7 +237,8 @@ public final class VolunteerRig {
if (facing < 0) { if (facing < 0) {
for (int i = 0; i < xs.length; i++) xs[i] = 1f - xs[i]; 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.PINNED
&& safeMotion != Motion.FROZEN && safeMotion != Motion.FROZEN
&& safeMotion != Motion.CONDUCTIVE; && safeMotion != Motion.CONDUCTIVE;
@@ -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());
}
}
@@ -18,6 +18,10 @@ public final class PhysicsConfigTest {
assertEquals(0.13f, physics.coyoteSeconds(), 0f); assertEquals(0.13f, physics.coyoteSeconds(), 0f);
assertEquals(0.14f, physics.jumpBufferSeconds(), 0f); assertEquals(0.14f, physics.jumpBufferSeconds(), 0f);
assertEquals(0.78f, physics.respawnSeconds(), 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); assertTrue(physics.maximumBodies() >= 10);
} }
} }
@@ -84,4 +84,32 @@ public final class VolunteerRigTest {
assertEquals(VolunteerRig.Motion.DEAD, assertEquals(VolunteerRig.Motion.DEAD,
VolunteerRig.bodyMotion(false, false, false)); 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);
}
} }
+6
View File
@@ -178,6 +178,12 @@ baseline-задачи также имели явного владельца по
- content не содержит blocker-softlocks и обязательных невосстановимых объектов; - content не содержит blocker-softlocks и обязательных невосстановимых объектов;
- принят `goon-game-milestone-production`. - принят `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 дней ### Этап 7. Техническая стабилизация и release engineering — P0, 4–8 дней
Задачи: Задачи:
+53
View File
@@ -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.