From 44067abde37525edffd9fc5efbd380dae9d7820e Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 14:44:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=B0=D1=87=D0=B0=D1=82=D1=8C=20=D1=8D?= =?UTF-8?q?=D1=82=D0=B0=D0=BF=203=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B0=D0=BC=D0=B8=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 2 +- README.md | 2 + app/build.gradle | 4 +- .../game/ControlSettingsPersistenceTest.java | 143 ++++++++++++++++++ .../game/ControlSettings.java | 103 +++++++++++++ .../game/ControlSettingsStore.java | 30 ++++ .../com/projectsacrifice/game/GameView.java | 140 +++++++++++++++-- .../game/ControlSettingsTest.java | 50 ++++++ docs/BEADS_WORKFLOW.md | 2 +- docs/CURRENT_ROADMAP.md | 7 +- docs/STAGE3_CONTROLS_QA.md | 30 ++++ 12 files changed, 492 insertions(+), 22 deletions(-) create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/ControlSettingsPersistenceTest.java create mode 100644 app/src/main/java/com/projectsacrifice/game/ControlSettings.java create mode 100644 app/src/main/java/com/projectsacrifice/game/ControlSettingsStore.java create mode 100644 app/src/test/java/com/projectsacrifice/game/ControlSettingsTest.java create mode 100644 docs/STAGE3_CONTROLS_QA.md diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 68e9c7d..0e2e08a 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -23,3 +23,4 @@ {"id":"int-2dabc667","kind":"field_change","created_at":"2026-08-18T01:30:46.06691Z","actor":"E.Gavrilov","issue_id":"goon-game-bots-6-10","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Уровни 6–10 проходят intended routes по 3 раза; уровни 8 и 10 дополнительно проходят 96ms stress checks."}} {"id":"int-2cbd2a1d","kind":"field_change","created_at":"2026-08-18T10:41:46.8206Z","actor":"E.Gavrilov","issue_id":"goon-game-level10-sliding-corpse","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Acceptance met: delivered body remains on lift plate, signal stays active, normal 3x and delayed-input level 10 routes pass."}} {"id":"int-a046aa8d","kind":"field_change","created_at":"2026-08-18T10:41:48.065633Z","actor":"E.Gavrilov","issue_id":"goon-game-level-route-risks","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All intended risky routes are reproduced by connected bots; possible L2 alternative is explicitly accepted and documented as non-blocking."}} +{"id":"int-33f283fa","kind":"field_change","created_at":"2026-08-18T11:43:04.54852Z","actor":"E.Gavrilov","issue_id":"goon-game-controls-settings","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Acceptance met: size, opacity and vibration settings persist; real touch targets and held-jump edge are tested; UI contract passes 16:9, 20:9 and tablet-sized displays."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index ef6a132..c29a608 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -39,7 +39,7 @@ {"_type":"issue","id":"goon-game-save-basic","title":"Базовое сохранение прогресса","description":"Сохраняются unlock, best time и минимальное число жертв.","acceptance_criteria":"Прогресс переживает перезапуск приложения.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:09Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:41:22Z","closed_at":"2026-08-17T21:41:22Z","close_reason":"Локальный прогресс и лучшие результаты сохраняются.","labels":["baseline","closed"],"dependencies":[{"issue_id":"goon-game-save-basic","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:26Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-accessibility","title":"Accessibility pass","description":"Проверить контраст, масштаб текста, safe areas, режим без вибрации и не-звуковые сигналы.","acceptance_criteria":"Критическая информация доступна визуально, элементы не перекрываются.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:06Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:06Z","labels":["accessibility","ui"],"dependencies":[{"issue_id":"goon-game-accessibility","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:25Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-menus-localization","title":"Меню, результаты и RU/EN локализация","description":"Добавить main/world/level select, settings, statistics, credits и полноценный result screen.","acceptance_criteria":"Все строки локализованы; игра проходится без объяснений разработчика.","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:40:04Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:04Z","labels":["localization","ui","vertical-slice"],"dependencies":[{"issue_id":"goon-game-menus-localization","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:24Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"goon-game-controls-settings","title":"Настройки размера, opacity и вибрации controls","description":"Дать игроку настройку touch controls и исключить случайные повторные прыжки.","acceptance_criteria":"Настройки сохраняются и проверены на 16:9, 20:9 и планшете.","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:40:02Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:02Z","labels":["current","ui","vertical-slice"],"dependencies":[{"issue_id":"goon-game-controls-settings","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:23Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"goon-game-controls-settings","title":"Настройки размера, opacity и вибрации controls","description":"Дать игроку настройку touch controls и исключить случайные повторные прыжки.","acceptance_criteria":"Настройки сохраняются и проверены на 16:9, 20:9 и планшете.","notes":"2026-08-18 Stage 3 controls slice complete. RED/GREEN unit contracts cover size/opacity/haptics presets. Production Settings screen changes values through real taps; SharedPreferences survives Activity recreation. Large preset expands real hit target, held jump does not retrigger after landing. Device GREEN: 20:9 class 3/3, logical 16:9 persistence 1/1, tablet-sized 1/1; lifecycle/restart/L5/L10 regressions 4/4; clean 20 JVM tests, lint and app/test APK build.","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:40:02Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T11:43:04Z","started_at":"2026-08-18T11:28:25Z","closed_at":"2026-08-18T11:43:04Z","close_reason":"Acceptance met: size, opacity and vibration settings persist; real touch targets and held-jump edge are tested; UI contract passes 16:9, 20:9 and tablet-sized displays.","labels":["current","ui","vertical-slice"],"dependencies":[{"issue_id":"goon-game-controls-settings","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:23Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"goon-game-ui-vslice","title":"Базовый HUD и multitouch controls","description":"В APK есть направления, jump, restart, pause, deaths и timer.","acceptance_criteria":"Управление работает одновременно двумя касаниями.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:00Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:41:21Z","closed_at":"2026-08-17T21:41:21Z","close_reason":"Базовый HUD и multitouch controls реализованы.","labels":["baseline","closed"],"dependencies":[{"issue_id":"goon-game-ui-vslice","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:22Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-camera-polish","title":"Доработать production camera controller","description":"Добавить vertical damping, room transitions, focus target и управляемый shake.","acceptance_criteria":"Камера не скрывает обязательные объекты и не вызывает дискомфорт.","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:50Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:39:50Z","labels":["camera","vertical-slice"],"dependencies":[{"issue_id":"goon-game-camera-polish","depends_on_id":"goon-game-e06","type":"parent-child","created_at":"2026-08-18T00:43:16Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"goon-game-camera-basic","title":"Базовая камера с look-ahead и bounds","description":"В текущем runtime реализовано сглаженное горизонтальное слежение.","acceptance_criteria":"Камера работает во всех 10 уровнях.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:39:45Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:41:18Z","closed_at":"2026-08-17T21:41:18Z","close_reason":"Базовое сглаженное слежение реализовано.","labels":["baseline","closed"],"dependencies":[{"issue_id":"goon-game-camera-basic","depends_on_id":"goon-game-e06","type":"parent-child","created_at":"2026-08-18T00:43:15Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/README.md b/README.md index 2db787e..e6b7a67 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ Death does not reset the level. A new volunteer respawns after a short delay whi - Hold a direction and tap jump simultaneously; multitouch is supported. - `Restart` restores the current level. - `II` pauses the game and exposes level selection. +- `Settings` on the level-select screen cycles three control sizes, three + opacity presets, and vibration on/off. Changes persist across app restarts. The movement constants include 130 ms coyote time and 140 ms input buffering. diff --git a/app/build.gradle b/app/build.gradle index 5686b23..ca520fa 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { applicationId 'com.projectsacrifice.game' minSdk 23 targetSdk 34 - versionCode 5 - versionName '0.4.0-stage2-complete' + versionCode 6 + versionName '0.5.0-stage3-controls' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } diff --git a/app/src/androidTest/java/com/projectsacrifice/game/ControlSettingsPersistenceTest.java b/app/src/androidTest/java/com/projectsacrifice/game/ControlSettingsPersistenceTest.java new file mode 100644 index 0000000..d22f237 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/ControlSettingsPersistenceTest.java @@ -0,0 +1,143 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.app.Instrumentation; +import android.os.SystemClock; +import android.view.MotionEvent; + +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 ControlSettingsPersistenceTest { + @Test + public void changedControlsSurviveActivityRecreation() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(current -> current.gameViewForAutomation() + .resetControlSettingsForAutomation()); + + tap(activity, 1_700f, 115f); + activity.onActivity(current -> assertTrue(current.gameViewForAutomation() + .settingsVisibleForAutomation())); + tap(activity, 1_250f, 345f); + tap(activity, 1_250f, 515f); + tap(activity, 1_250f, 685f); + + activity.recreate(); + + activity.onActivity(current -> { + ControlSettings settings = current.gameViewForAutomation() + .controlSettingsForAutomation(); + assertEquals("Large", settings.controlSizeLabel()); + assertEquals(1.2f, settings.controlScale(), 0f); + assertEquals("High", settings.controlOpacityLabel()); + assertEquals(0.9f, settings.controlOpacity(), 0f); + assertFalse(settings.hapticsEnabled()); + assertFalse(current.gameViewForAutomation().settingsVisibleForAutomation()); + current.gameViewForAutomation().resetControlSettingsForAutomation(); + }); + } + } + + @Test + public void largePresetExpandsTheRealTouchTarget() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + view.resetControlSettingsForAutomation(); + view.startLevelForAutomation(1); + }); + + touch(activity, MotionEvent.ACTION_DOWN, 13f, 925f); + activity.onActivity(current -> assertFalse(current.gameViewForAutomation() + .automationSnapshot().inputActive())); + touch(activity, MotionEvent.ACTION_UP, 13f, 925f); + + activity.onActivity(current -> current.gameViewForAutomation() + .selectNextControlSizeForAutomation()); + touch(activity, MotionEvent.ACTION_DOWN, 13f, 925f); + activity.onActivity(current -> { + assertTrue(current.gameViewForAutomation().automationSnapshot().inputActive()); + current.gameViewForAutomation().resetControlSettingsForAutomation(); + }); + touch(activity, MotionEvent.ACTION_UP, 13f, 925f); + } + } + + @Test + public void heldJumpDoesNotRepeatAfterLanding() 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(); + view[0].startLevelForAutomation(1); + }); + + await(instrumentation, view[0], 1_000L, + GameView.AutomationSnapshot::playerOnGround); + instrumentation.runOnMainSync( + () -> view[0].setAutomationInput(false, false, true)); + await(instrumentation, view[0], 2_000L, + snapshot -> snapshot.playerY() <= 700f); + await(instrumentation, view[0], 2_000L, + snapshot -> snapshot.playerOnGround() && snapshot.playerY() >= 779f); + Thread.sleep(350L); + + GameView.AutomationSnapshot settled = snapshot(instrumentation, view[0]); + assertTrue("A held jump must not fire again after landing", settled.playerOnGround()); + assertEquals(780f, settled.playerY(), 1f); + instrumentation.runOnMainSync( + () -> view[0].setAutomationInput(false, false, false)); + } + } + + private static void tap(ActivityScenario activity, float canvasX, float canvasY) { + touch(activity, MotionEvent.ACTION_DOWN, canvasX, canvasY); + touch(activity, MotionEvent.ACTION_UP, canvasX, canvasY); + } + + private static void touch(ActivityScenario activity, int action, + float canvasX, float canvasY) { + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + float scale = Math.min(view.getWidth() / 1_920f, view.getHeight() / 1_080f); + float x = (view.getWidth() - 1_920f * scale) * 0.5f + canvasX * scale; + float y = (view.getHeight() - 1_080f * scale) * 0.5f + canvasY * scale; + long now = SystemClock.uptimeMillis(); + MotionEvent event = MotionEvent.obtain(now, now, action, x, y, 0); + view.dispatchTouchEvent(event); + event.recycle(); + }); + } + + private static GameView.AutomationSnapshot await( + Instrumentation instrumentation, GameView view, long timeoutMillis, + LevelPlaytestBot.SnapshotCondition condition + ) throws Exception { + long deadline = System.currentTimeMillis() + timeoutMillis; + GameView.AutomationSnapshot current = snapshot(instrumentation, view); + while (System.currentTimeMillis() < deadline) { + current = snapshot(instrumentation, view); + if (condition.reached(current)) return current; + Thread.sleep(32L); + } + throw new AssertionError("Control input timed out; x=" + current.playerX() + + ", y=" + current.playerY() + ", input=" + current.inputActive()); + } + + private static GameView.AutomationSnapshot snapshot( + Instrumentation instrumentation, GameView view + ) { + GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1]; + instrumentation.runOnMainSync(() -> value[0] = view.automationSnapshot()); + return value[0]; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/ControlSettings.java b/app/src/main/java/com/projectsacrifice/game/ControlSettings.java new file mode 100644 index 0000000..9f65264 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/ControlSettings.java @@ -0,0 +1,103 @@ +package com.projectsacrifice.game; + +public final class ControlSettings { + private enum Size { + SMALL("Small", 0.85f), + MEDIUM("Medium", 1f), + LARGE("Large", 1.2f); + + final String label; + final float scale; + + Size(String label, float scale) { + this.label = label; + this.scale = scale; + } + } + + private enum Opacity { + LOW("Low", 0.45f), + MEDIUM("Medium", 0.65f), + HIGH("High", 0.9f); + + final String label; + final float alpha; + + Opacity(String label, float alpha) { + this.label = label; + this.alpha = alpha; + } + } + + private final Size size; + private final Opacity opacity; + private final boolean hapticsEnabled; + + private ControlSettings(Size size, Opacity opacity, boolean hapticsEnabled) { + this.size = size; + this.opacity = opacity; + this.hapticsEnabled = hapticsEnabled; + } + + public static ControlSettings defaults() { + return new ControlSettings(Size.MEDIUM, Opacity.MEDIUM, true); + } + + static ControlSettings restore(int sizeIndex, int opacityIndex, boolean hapticsEnabled) { + Size[] sizes = Size.values(); + Opacity[] opacities = Opacity.values(); + if (sizeIndex < 0 || sizeIndex >= sizes.length + || opacityIndex < 0 || opacityIndex >= opacities.length) { + return defaults(); + } + return new ControlSettings(sizes[sizeIndex], opacities[opacityIndex], hapticsEnabled); + } + + public String controlSizeLabel() { + return size.label; + } + + public float controlScale() { + return size.scale; + } + + public ControlSettings nextControlSize() { + Size[] values = Size.values(); + return new ControlSettings(values[(size.ordinal() + 1) % values.length], opacity, + hapticsEnabled); + } + + public String controlOpacityLabel() { + return opacity.label; + } + + public float controlOpacity() { + return opacity.alpha; + } + + public ControlSettings nextControlOpacity() { + Opacity[] values = Opacity.values(); + return new ControlSettings(size, values[(opacity.ordinal() + 1) % values.length], + hapticsEnabled); + } + + public boolean hapticsEnabled() { + return hapticsEnabled; + } + + public String hapticsLabel() { + return hapticsEnabled ? "On" : "Off"; + } + + public ControlSettings toggleHaptics() { + return new ControlSettings(size, opacity, !hapticsEnabled); + } + + int controlSizeIndex() { + return size.ordinal(); + } + + int controlOpacityIndex() { + return opacity.ordinal(); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/ControlSettingsStore.java b/app/src/main/java/com/projectsacrifice/game/ControlSettingsStore.java new file mode 100644 index 0000000..5850641 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/ControlSettingsStore.java @@ -0,0 +1,30 @@ +package com.projectsacrifice.game; + +import android.content.Context; +import android.content.SharedPreferences; + +final class ControlSettingsStore { + private static final String FILE = "project_sacrifice_control_settings_v1"; + private final SharedPreferences preferences; + + ControlSettingsStore(Context context) { + preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE); + } + + ControlSettings load() { + ControlSettings defaults = ControlSettings.defaults(); + return ControlSettings.restore( + preferences.getInt("size", defaults.controlSizeIndex()), + preferences.getInt("opacity", defaults.controlOpacityIndex()), + preferences.getBoolean("haptics", defaults.hapticsEnabled()) + ); + } + + void save(ControlSettings settings) { + preferences.edit() + .putInt("size", settings.controlSizeIndex()) + .putInt("opacity", settings.controlOpacityIndex()) + .putBoolean("haptics", settings.hapticsEnabled()) + .apply(); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index 7a0bd8a..610967b 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -38,7 +38,7 @@ public final class GameView extends View implements Runnable { private static final float RESPAWN_SECONDS = 0.78f; private static final int MAX_CORPSES = 16; - private enum Screen { LEVELS, PLAYING, COMPLETE } + private enum Screen { LEVELS, SETTINGS, PLAYING, COMPLETE } private enum DeathType { SPIKE, SAW, FREEZE, ELECTRIC, PIT } public static final class AutomationSnapshot { @@ -160,6 +160,7 @@ public final class GameView extends View implements Runnable { private final LinearGradient skyGradient = new LinearGradient( 0, 0, 0, VIEW_HEIGHT, Color.rgb(20, 29, 55), Color.rgb(110, 81, 91), Shader.TileMode.CLAMP); private final ProgressStore progress; + private final ControlSettingsStore controlSettingsStore; private final SparseArray pointers = new SparseArray<>(); private final Player player = new Player(); private final ArrayList corpses = new ArrayList<>(); @@ -185,10 +186,13 @@ public final class GameView extends View implements Runnable { private float canvasScale = 1f; private float canvasOffsetX; private float canvasOffsetY; + private ControlSettings controlSettings; public GameView(Context context) { super(context); progress = new ProgressStore(context); + controlSettingsStore = new ControlSettingsStore(context); + controlSettings = controlSettingsStore.load(); paint.setTypeface(android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL)); stroke.setStyle(Paint.Style.STROKE); stroke.setStrokeCap(Paint.Cap.ROUND); @@ -227,6 +231,10 @@ public final class GameView extends View implements Runnable { public boolean handleBack() { if (screen == Screen.LEVELS) return false; + if (screen == Screen.SETTINGS) { + screen = Screen.LEVELS; + return true; + } if (screen == Screen.COMPLETE) { screen = Screen.LEVELS; return true; @@ -315,11 +323,11 @@ public final class GameView extends View implements Runnable { boolean previous = Boolean.TRUE.equals(previousSwitchContact.get(contactKey)); if (item.type == LevelDefinition.SwitchType.TOGGLE && contact && !previous) { toggleSignals.put(item.signalId, !Boolean.TRUE.equals(toggleSignals.get(item.signalId))); - performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); + emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); } if (item.type == LevelDefinition.SwitchType.TIMED && contact && !previous) { timedSignals.put(item.signalId, item.durationSeconds); - performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); + emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); } previousSwitchContact.put(contactKey, contact); } @@ -404,7 +412,7 @@ public final class GameView extends View implements Runnable { player.onGround = false; player.coyote = 0f; player.jumpBuffer = 0f; - performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK); + emitHaptic(HapticFeedbackConstants.CLOCK_TICK); } player.vy += GRAVITY * dt; @@ -577,7 +585,7 @@ public final class GameView extends View implements Runnable { player.supportIndex = -1; deaths++; respawnTimer = RESPAWN_SECONDS; - performHapticFeedback(Build.VERSION.SDK_INT >= 27 + emitHaptic(Build.VERSION.SDK_INT >= 27 ? HapticFeedbackConstants.LONG_PRESS : HapticFeedbackConstants.KEYBOARD_TAP); if (type != DeathType.PIT) { @@ -619,7 +627,7 @@ public final class GameView extends View implements Runnable { screen = Screen.COMPLETE; pointers.clear(); deriveControls(); - performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + emitHaptic(HapticFeedbackConstants.LONG_PRESS); } private RectF playerRect() { @@ -650,6 +658,8 @@ public final class GameView extends View implements Runnable { canvas.scale(canvasScale, canvasScale); if (screen == Screen.LEVELS) { drawLevelSelect(canvas); + } else if (screen == Screen.SETTINGS) { + drawSettings(canvas); } else { drawGame(canvas); if (screen == Screen.COMPLETE) drawComplete(canvas); @@ -917,21 +927,27 @@ public final class GameView extends View implements Runnable { drawHudButton(canvas, new RectF(1608, 26, 1770, 112), "Restart", false); drawHudButton(canvas, new RectF(1790, 26, 1892, 112), "II", false); - drawControlCircle(canvas, 145, 925, 92, "‹", leftPressed); - drawControlCircle(canvas, 365, 925, 92, "›", rightPressed); - drawControlCircle(canvas, 1760, 900, 116, "▲", jumpPressed); + float controlScale = controlSettings.controlScale(); + drawControlCircle(canvas, 145, 925, 92 * controlScale, "‹", leftPressed); + drawControlCircle(canvas, 365, 925, 92 * controlScale, "›", rightPressed); + drawControlCircle(canvas, 1760, 900, 116 * controlScale, "▲", jumpPressed); if (!player.alive) { drawCenteredText(canvas, "Another volunteer is on the way…", VIEW_WIDTH * 0.5f, 235, 38, Color.rgb(250, 222, 153)); } } private void drawControlCircle(Canvas canvas, float x, float y, float radius, String label, boolean active) { - paint.setColor(active ? Color.argb(220, 245, 190, 74) : Color.argb(120, 22, 28, 43)); + int inactiveFillAlpha = Math.round(190f * controlSettings.controlOpacity()); + int inactiveStrokeAlpha = Math.round(230f * controlSettings.controlOpacity()); + paint.setColor(active ? Color.argb(220, 245, 190, 74) + : Color.argb(inactiveFillAlpha, 22, 28, 43)); canvas.drawCircle(x, y, radius, paint); - stroke.setColor(active ? Color.rgb(255, 232, 169) : Color.argb(150, 240, 240, 245)); + stroke.setColor(active ? Color.rgb(255, 232, 169) + : Color.argb(inactiveStrokeAlpha, 240, 240, 245)); stroke.setStrokeWidth(5f); canvas.drawCircle(x, y, radius - 5, stroke); - drawCenteredText(canvas, label, x, y + 21, 68, active ? Color.rgb(34, 36, 45) : Color.WHITE); + drawCenteredText(canvas, label, x, y + 21, 68 * controlSettings.controlScale(), + active ? Color.rgb(34, 36, 45) : Color.WHITE); } private void drawLevelSelect(Canvas canvas) { @@ -968,9 +984,36 @@ public final class GameView extends View implements Runnable { drawCenteredText(canvas, "Tap to enter", card.centerX(), top + 190, 22, Color.rgb(202, 204, 214)); } } + drawHudButton(canvas, new RectF(1560, 70, 1850, 160), "Settings", false); drawCenteredText(canvas, "Landscape · multitouch · progress saves automatically", VIEW_WIDTH * 0.5f, 980, 24, Color.argb(190, 230, 230, 236)); } + private void drawSettings(Canvas canvas) { + drawSky(canvas); + drawCenteredText(canvas, "SETTINGS", VIEW_WIDTH * 0.5f, 145, 64, + Color.rgb(248, 212, 126)); + drawCenteredText(canvas, "Tap a row to change it. Changes save automatically.", + VIEW_WIDTH * 0.5f, 205, 27, Color.rgb(225, 226, 233)); + drawSettingRow(canvas, new RectF(420, 280, 1500, 410), "Control size", + controlSettings.controlSizeLabel()); + drawSettingRow(canvas, new RectF(420, 450, 1500, 580), "Control opacity", + controlSettings.controlOpacityLabel()); + drawSettingRow(canvas, new RectF(420, 620, 1500, 750), "Vibration", + controlSettings.hapticsLabel()); + drawHudButton(canvas, new RectF(710, 850, 1210, 960), "Back", true); + } + + private void drawSettingRow(Canvas canvas, RectF box, String label, String value) { + paint.setColor(Color.argb(220, 38, 45, 65)); + canvas.drawRoundRect(box, 22, 22, paint); + stroke.setColor(Color.rgb(139, 145, 160)); + stroke.setStrokeWidth(4f); + canvas.drawRoundRect(box, 22, 22, stroke); + drawText(canvas, label, box.left + 55f, box.centerY() + 11f, 32f, Color.WHITE, true); + drawHudButton(canvas, new RectF(1_090f, box.top + 24f, 1_445f, box.bottom - 24f), + value, false); + } + private void drawComplete(Canvas canvas) { paint.setColor(Color.argb(218, 8, 12, 23)); canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint); @@ -1047,6 +1090,13 @@ public final class GameView extends View implements Runnable { } return true; } + if (screen == Screen.SETTINGS) { + if (action == MotionEvent.ACTION_UP) { + performClick(); + handleSettingsTap(point.x, point.y); + } + return true; + } if (screen == Screen.COMPLETE) { if (action == MotionEvent.ACTION_UP) { performClick(); @@ -1106,14 +1156,20 @@ public final class GameView extends View implements Runnable { jumpPressed = false; for (int i = 0; i < pointers.size(); i++) { PointF point = pointers.valueAt(i); - if (distance(point.x, point.y, 145, 925) <= 115f) leftPressed = true; - if (distance(point.x, point.y, 365, 925) <= 115f) rightPressed = true; - if (distance(point.x, point.y, 1760, 900) <= 145f) jumpPressed = true; + float controlScale = controlSettings.controlScale(); + if (distance(point.x, point.y, 145, 925) <= 115f * controlScale) leftPressed = true; + if (distance(point.x, point.y, 365, 925) <= 115f * controlScale) rightPressed = true; + if (distance(point.x, point.y, 1760, 900) <= 145f * controlScale) jumpPressed = true; } if (jumpPressed && !wasJumpPressed && player.alive) player.jumpBuffer = JUMP_BUFFER_SECONDS; } private void handleLevelSelectTap(float x, float y) { + if (contains(1560, 70, 1850, 160, x, y)) { + screen = Screen.SETTINGS; + emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); + return; + } float startX = 110f; float cardW = 310f; float cardH = 250f; @@ -1124,7 +1180,7 @@ public final class GameView extends View implements Runnable { float left = startX + col * (cardW + gapX); float top = 260f + row * 310f; if (contains(left, top, left + cardW, top + cardH, x, y) && i < progress.unlockedCount()) { - performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); + emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); startLevel(i); return; } @@ -1145,6 +1201,26 @@ public final class GameView extends View implements Runnable { } } + private void handleSettingsTap(float x, float y) { + if (contains(420, 280, 1500, 410, x, y)) { + updateControlSettings(controlSettings.nextControlSize()); + emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); + } else if (contains(420, 450, 1500, 580, x, y)) { + updateControlSettings(controlSettings.nextControlOpacity()); + emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); + } else if (contains(420, 620, 1500, 750, x, y)) { + updateControlSettings(controlSettings.toggleHaptics()); + emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); + } else if (contains(710, 850, 1210, 960, x, y)) { + screen = Screen.LEVELS; + emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); + } + } + + private void emitHaptic(int feedbackConstant) { + if (controlSettings.hapticsEnabled()) performHapticFeedback(feedbackConstant); + } + private PointF toCanvasPoint(float x, float y) { return new PointF((x - canvasOffsetX) / Math.max(0.001f, canvasScale), (y - canvasOffsetY) / Math.max(0.001f, canvasScale)); } @@ -1170,6 +1246,38 @@ public final class GameView extends View implements Runnable { restartLevel(); } + public ControlSettings controlSettingsForAutomation() { + return controlSettings; + } + + public boolean settingsVisibleForAutomation() { + return screen == Screen.SETTINGS; + } + + public void resetControlSettingsForAutomation() { + updateControlSettings(ControlSettings.defaults()); + } + + public void selectNextControlSizeForAutomation() { + updateControlSettings(controlSettings.nextControlSize()); + } + + public void selectNextControlOpacityForAutomation() { + updateControlSettings(controlSettings.nextControlOpacity()); + } + + public void toggleHapticsForAutomation() { + updateControlSettings(controlSettings.toggleHaptics()); + } + + private void updateControlSettings(ControlSettings updated) { + controlSettings = updated; + controlSettingsStore.save(updated); + pointers.clear(); + deriveControls(); + invalidate(); + } + public void setAutomationInput(boolean moveLeft, boolean moveRight, boolean jump) { boolean jumpWasPressed = jumpPressed; leftPressed = moveLeft; diff --git a/app/src/test/java/com/projectsacrifice/game/ControlSettingsTest.java b/app/src/test/java/com/projectsacrifice/game/ControlSettingsTest.java new file mode 100644 index 0000000..beaaead --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/ControlSettingsTest.java @@ -0,0 +1,50 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public final class ControlSettingsTest { + @Test + public void controlSizeCyclesThroughAccessiblePresetsAndWraps() { + ControlSettings settings = ControlSettings.defaults(); + + assertEquals("Medium", settings.controlSizeLabel()); + assertEquals(1f, settings.controlScale(), 0f); + + settings = settings.nextControlSize(); + assertEquals("Large", settings.controlSizeLabel()); + assertEquals(1.2f, settings.controlScale(), 0f); + + settings = settings.nextControlSize().nextControlSize(); + assertEquals("Medium", settings.controlSizeLabel()); + assertEquals(1f, settings.controlScale(), 0f); + } + + @Test + public void controlOpacityCyclesWithoutMakingControlsInvisible() { + ControlSettings settings = ControlSettings.defaults(); + + assertEquals("Medium", settings.controlOpacityLabel()); + assertEquals(0.65f, settings.controlOpacity(), 0f); + + settings = settings.nextControlOpacity(); + assertEquals("High", settings.controlOpacityLabel()); + assertEquals(0.9f, settings.controlOpacity(), 0f); + + settings = settings.nextControlOpacity(); + assertEquals("Low", settings.controlOpacityLabel()); + assertEquals(0.45f, settings.controlOpacity(), 0f); + } + + @Test + public void vibrationCanBeDisabledAndEnabledAgain() { + ControlSettings settings = ControlSettings.defaults(); + + assertEquals(true, settings.hapticsEnabled()); + settings = settings.toggleHaptics(); + assertEquals(false, settings.hapticsEnabled()); + assertEquals("Off", settings.hapticsLabel()); + assertEquals(true, settings.toggleHaptics().hapticsEnabled()); + } +} diff --git a/docs/BEADS_WORKFLOW.md b/docs/BEADS_WORKFLOW.md index 02cea22..6161523 100644 --- a/docs/BEADS_WORKFLOW.md +++ b/docs/BEADS_WORKFLOW.md @@ -22,7 +22,7 @@ master tracks origin/master - предметные задачи с acceptance criteria, приоритетами и зависимостями; - уже подтверждённые кодом и QA элементы закрыты с причиной. -На 18 августа 2026 года в snapshot находятся 72 beads: 21 закрыт, 6 находятся в работе и 45 открыты. Эти числа нельзя трактовать как точный процент готовности, потому что в базе смешаны эпики, features и задачи разного размера. +На 18 августа 2026 года в snapshot находятся 72 beads: 22 закрыты, 6 находятся в работе и 44 открыты. Эти числа нельзя трактовать как точный процент готовности, потому что в базе смешаны эпики, features и задачи разного размера. Текущий gate — `goon-game-milestone-vslice`. Инженерный кандидат этапа 2 собран; незакрытые gates включают `goon-game-human-playtest` и performance/repetition criteria `goon-game-regression-10`. Задачи этапа 3 можно начинать параллельно, не объявляя внешний playtest завершённым. diff --git a/docs/CURRENT_ROADMAP.md b/docs/CURRENT_ROADMAP.md index 2ae88ac..9c00773 100644 --- a/docs/CURRENT_ROADMAP.md +++ b/docs/CURRENT_ROADMAP.md @@ -6,7 +6,7 @@ ## 1. Текущее состояние -Инженерная часть этапа 2 завершена и проект готов к параллельному старту этапа 3: +Инженерная часть этапа 2 завершена; этап 3 начат с настроек мобильного управления: - реализованы 10 последовательных уровней; - работают движение, прыжок и мобильное multitouch-управление; @@ -18,6 +18,7 @@ - исправлено перекрытие `FREEZER` уровня 6, которое повторно убивало игрока на frozen stepping stone; - тело, доставленное пилой на `PRESSURE` уровня 10, фиксируется после оседания и больше не соскальзывает с плиты; - restart из промежуточного состояния уровня 10 очищает тела, сигналы, таймер и удерживаемый ввод; +- Settings screen сохраняет размер и opacity touch-controls и режим вибрации; три form-factor контракта и защита от повторного held-jump покрыты device-тестами; - final integrated Android 16 matrix: 16/16 методов, 36 игровых выполнений, lifecycle и restart; wall time `9:32.496`; - финальный debug APK проверен на Android 16: установка, холодный запуск, ZIP-структура и v1/v2-подписи корректны. @@ -179,4 +180,6 @@ Performance-оговорка: исторический критерий этап Инженерный scope этапа 2 закрыт: уровни 1–10 проходят intended route по три раза, уровень 8 имеет restart-free recovery, уровень 10 использует все три тела и сохраняет тело на lift-плите, а restart возвращает чистое состояние. Передаваемые результаты — обновлённый проект, проверенный APK и [`STAGE2_QA_REPORT.md`](STAGE2_QA_REPORT.md). -Следующая реализационная итерация — этап 3 (управление, интерфейс и доступность). Параллельно внешний QA должен выполнить `goon-game-human-playtest`: по две ручные сессии каждого уровня без подсказок разработчика. До получения этого протокола milestone Vertical Slice нельзя называть принятым внешними тестировщиками, хотя разработку этапа 3 можно начинать. +Этап 3 начат задачей `goon-game-controls-settings`: добавлены сохраняемые пресеты размера/opacity controls и вибрации, фактическое расширение touch target и защита от повторного прыжка при удержании. Проверки выполнены на 20:9, логическом 16:9 и tablet-sized экране; подробности находятся в [`STAGE3_CONTROLS_QA.md`](STAGE3_CONTROLS_QA.md). Следующие slices этапа 3 — accessibility, обучение/result screen и RU/EN локализация. + +Параллельно внешний QA должен выполнить `goon-game-human-playtest`: по две ручные сессии каждого уровня без подсказок разработчика. До получения этого протокола milestone Vertical Slice нельзя называть принятым внешними тестировщиками. diff --git a/docs/STAGE3_CONTROLS_QA.md b/docs/STAGE3_CONTROLS_QA.md new file mode 100644 index 0000000..9aa85e0 --- /dev/null +++ b/docs/STAGE3_CONTROLS_QA.md @@ -0,0 +1,30 @@ +# Stage 3 controls QA + +Дата: 18 августа 2026 года. Сборка: `0.5.0-stage3-controls-debug` (`versionCode=6`). Активная Beads-задача: `goon-game-controls-settings`. + +## Реализованный slice + +- Settings screen доступен с level-select screen и возвращается назад системной кнопкой или кнопкой `Back`; +- control size: `Small` (`0.85×`), `Medium` (`1.0×`), `Large` (`1.2×`); +- control opacity: `Low` (`0.45`), `Medium` (`0.65`), `High` (`0.9`); +- vibration: `On`/`Off`; все игровые haptic events проходят через сохранённую настройку; +- размер одновременно меняет визуальный радиус и фактический touch target; +- значения сохраняются через отдельный versioned `SharedPreferences` store и загружаются новой Activity. + +## TDD evidence + +- RED: отсутствовал `ControlSettings`; unit-test не компилировался; +- GREEN: cycling и безопасные defaults size/opacity/vibration; +- RED: Android persistence contract не компилировался без public Settings surface; +- GREEN: реальные taps меняют три настройки, Activity recreation загружает `Large`/`High`/`Off`; +- device regression: точка вне `Medium`, но внутри `Large`, активирует production touch input; +- device regression: удерживаемый jump выполняется один раз и не срабатывает повторно после приземления. + +## Form factors и регрессии + +- 20:9 physical AVD (`2400×1080`, density `420`): Settings test `OK (3 tests)`; +- logical 16:9 (`1920×1080`): persistence/touch UI `OK (1 test)`; +- tablet-sized logical display (`2560×1600`, density `320`): persistence/touch UI `OK (1 test)`; +- existing lifecycle, clean restart, Level 5 three-run bot и Level 10 retained-body test: `OK (4 tests)`. + +Логические display overrides проверяют масштабирование и hit mapping на одном Android 16 AVD; реальное tablet hardware остаётся частью последующего device-matrix этапа.