Начать этап 3 настройками управления

This commit is contained in:
2026-08-18 14:44:30 +03:00
parent 80822b74b1
commit 44067abde3
12 changed files with 492 additions and 22 deletions
+1
View File
@@ -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-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-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-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."}}
+1 -1
View File
@@ -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-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-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-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-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-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} {"_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}
+2
View File
@@ -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. - Hold a direction and tap jump simultaneously; multitouch is supported.
- `Restart` restores the current level. - `Restart` restores the current level.
- `II` pauses the game and exposes level selection. - `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. The movement constants include 130 ms coyote time and 140 ms input buffering.
+2 -2
View File
@@ -10,8 +10,8 @@ android {
applicationId 'com.projectsacrifice.game' applicationId 'com.projectsacrifice.game'
minSdk 23 minSdk 23
targetSdk 34 targetSdk 34
versionCode 5 versionCode 6
versionName '0.4.0-stage2-complete' versionName '0.5.0-stage3-controls'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
} }
@@ -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<MainActivity> 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<MainActivity> 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<MainActivity> 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<MainActivity> activity, float canvasX, float canvasY) {
touch(activity, MotionEvent.ACTION_DOWN, canvasX, canvasY);
touch(activity, MotionEvent.ACTION_UP, canvasX, canvasY);
}
private static void touch(ActivityScenario<MainActivity> 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];
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -38,7 +38,7 @@ public final class GameView extends View implements Runnable {
private static final float RESPAWN_SECONDS = 0.78f; private static final float RESPAWN_SECONDS = 0.78f;
private static final int MAX_CORPSES = 16; 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 } private enum DeathType { SPIKE, SAW, FREEZE, ELECTRIC, PIT }
public static final class AutomationSnapshot { public static final class AutomationSnapshot {
@@ -160,6 +160,7 @@ public final class GameView extends View implements Runnable {
private final LinearGradient skyGradient = new LinearGradient( private final LinearGradient skyGradient = new LinearGradient(
0, 0, 0, VIEW_HEIGHT, Color.rgb(20, 29, 55), Color.rgb(110, 81, 91), Shader.TileMode.CLAMP); 0, 0, 0, VIEW_HEIGHT, Color.rgb(20, 29, 55), Color.rgb(110, 81, 91), Shader.TileMode.CLAMP);
private final ProgressStore progress; private final ProgressStore progress;
private final ControlSettingsStore controlSettingsStore;
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 ArrayList<Corpse> corpses = new ArrayList<>(); private final ArrayList<Corpse> corpses = new ArrayList<>();
@@ -185,10 +186,13 @@ public final class GameView extends View implements Runnable {
private float canvasScale = 1f; private float canvasScale = 1f;
private float canvasOffsetX; private float canvasOffsetX;
private float canvasOffsetY; private float canvasOffsetY;
private ControlSettings controlSettings;
public GameView(Context context) { public GameView(Context context) {
super(context); super(context);
progress = new ProgressStore(context); progress = new ProgressStore(context);
controlSettingsStore = new ControlSettingsStore(context);
controlSettings = controlSettingsStore.load();
paint.setTypeface(android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL)); paint.setTypeface(android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL));
stroke.setStyle(Paint.Style.STROKE); stroke.setStyle(Paint.Style.STROKE);
stroke.setStrokeCap(Paint.Cap.ROUND); stroke.setStrokeCap(Paint.Cap.ROUND);
@@ -227,6 +231,10 @@ public final class GameView extends View implements Runnable {
public boolean handleBack() { public boolean handleBack() {
if (screen == Screen.LEVELS) return false; if (screen == Screen.LEVELS) return false;
if (screen == Screen.SETTINGS) {
screen = Screen.LEVELS;
return true;
}
if (screen == Screen.COMPLETE) { if (screen == Screen.COMPLETE) {
screen = Screen.LEVELS; screen = Screen.LEVELS;
return true; return true;
@@ -315,11 +323,11 @@ public final class GameView extends View implements Runnable {
boolean previous = Boolean.TRUE.equals(previousSwitchContact.get(contactKey)); boolean previous = Boolean.TRUE.equals(previousSwitchContact.get(contactKey));
if (item.type == LevelDefinition.SwitchType.TOGGLE && contact && !previous) { if (item.type == LevelDefinition.SwitchType.TOGGLE && contact && !previous) {
toggleSignals.put(item.signalId, !Boolean.TRUE.equals(toggleSignals.get(item.signalId))); 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) { if (item.type == LevelDefinition.SwitchType.TIMED && contact && !previous) {
timedSignals.put(item.signalId, item.durationSeconds); timedSignals.put(item.signalId, item.durationSeconds);
performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
} }
previousSwitchContact.put(contactKey, contact); previousSwitchContact.put(contactKey, contact);
} }
@@ -404,7 +412,7 @@ public final class GameView extends View implements Runnable {
player.onGround = false; player.onGround = false;
player.coyote = 0f; player.coyote = 0f;
player.jumpBuffer = 0f; player.jumpBuffer = 0f;
performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK); emitHaptic(HapticFeedbackConstants.CLOCK_TICK);
} }
player.vy += GRAVITY * dt; player.vy += GRAVITY * dt;
@@ -577,7 +585,7 @@ public final class GameView extends View implements Runnable {
player.supportIndex = -1; player.supportIndex = -1;
deaths++; deaths++;
respawnTimer = RESPAWN_SECONDS; respawnTimer = RESPAWN_SECONDS;
performHapticFeedback(Build.VERSION.SDK_INT >= 27 emitHaptic(Build.VERSION.SDK_INT >= 27
? HapticFeedbackConstants.LONG_PRESS : HapticFeedbackConstants.KEYBOARD_TAP); ? HapticFeedbackConstants.LONG_PRESS : HapticFeedbackConstants.KEYBOARD_TAP);
if (type != DeathType.PIT) { if (type != DeathType.PIT) {
@@ -619,7 +627,7 @@ public final class GameView extends View implements Runnable {
screen = Screen.COMPLETE; screen = Screen.COMPLETE;
pointers.clear(); pointers.clear();
deriveControls(); deriveControls();
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); emitHaptic(HapticFeedbackConstants.LONG_PRESS);
} }
private RectF playerRect() { private RectF playerRect() {
@@ -650,6 +658,8 @@ public final class GameView extends View implements Runnable {
canvas.scale(canvasScale, canvasScale); canvas.scale(canvasScale, canvasScale);
if (screen == Screen.LEVELS) { if (screen == Screen.LEVELS) {
drawLevelSelect(canvas); drawLevelSelect(canvas);
} else if (screen == Screen.SETTINGS) {
drawSettings(canvas);
} else { } else {
drawGame(canvas); drawGame(canvas);
if (screen == Screen.COMPLETE) drawComplete(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(1608, 26, 1770, 112), "Restart", false);
drawHudButton(canvas, new RectF(1790, 26, 1892, 112), "II", false); drawHudButton(canvas, new RectF(1790, 26, 1892, 112), "II", false);
drawControlCircle(canvas, 145, 925, 92, "‹", leftPressed); float controlScale = controlSettings.controlScale();
drawControlCircle(canvas, 365, 925, 92, "›", rightPressed); drawControlCircle(canvas, 145, 925, 92 * controlScale, "‹", leftPressed);
drawControlCircle(canvas, 1760, 900, 116, "▲", jumpPressed); drawControlCircle(canvas, 365, 925, 92 * controlScale, "›", rightPressed);
drawControlCircle(canvas, 1760, 900, 116 * controlScale, "▲", jumpPressed);
if (!player.alive) { if (!player.alive) {
drawCenteredText(canvas, "Another volunteer is on the way…", VIEW_WIDTH * 0.5f, 235, 38, Color.rgb(250, 222, 153)); 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) { 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); 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); stroke.setStrokeWidth(5f);
canvas.drawCircle(x, y, radius - 5, stroke); 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) { 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)); 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)); 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) { private void drawComplete(Canvas canvas) {
paint.setColor(Color.argb(218, 8, 12, 23)); paint.setColor(Color.argb(218, 8, 12, 23));
canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint); canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint);
@@ -1047,6 +1090,13 @@ public final class GameView extends View implements Runnable {
} }
return true; return true;
} }
if (screen == Screen.SETTINGS) {
if (action == MotionEvent.ACTION_UP) {
performClick();
handleSettingsTap(point.x, point.y);
}
return true;
}
if (screen == Screen.COMPLETE) { if (screen == Screen.COMPLETE) {
if (action == MotionEvent.ACTION_UP) { if (action == MotionEvent.ACTION_UP) {
performClick(); performClick();
@@ -1106,14 +1156,20 @@ public final class GameView extends View implements Runnable {
jumpPressed = false; jumpPressed = false;
for (int i = 0; i < pointers.size(); i++) { for (int i = 0; i < pointers.size(); i++) {
PointF point = pointers.valueAt(i); PointF point = pointers.valueAt(i);
if (distance(point.x, point.y, 145, 925) <= 115f) leftPressed = true; float controlScale = controlSettings.controlScale();
if (distance(point.x, point.y, 365, 925) <= 115f) rightPressed = true; if (distance(point.x, point.y, 145, 925) <= 115f * controlScale) leftPressed = true;
if (distance(point.x, point.y, 1760, 900) <= 145f) jumpPressed = 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; if (jumpPressed && !wasJumpPressed && player.alive) player.jumpBuffer = JUMP_BUFFER_SECONDS;
} }
private void handleLevelSelectTap(float x, float y) { 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 startX = 110f;
float cardW = 310f; float cardW = 310f;
float cardH = 250f; float cardH = 250f;
@@ -1124,7 +1180,7 @@ public final class GameView extends View implements Runnable {
float left = startX + col * (cardW + gapX); float left = startX + col * (cardW + gapX);
float top = 260f + row * 310f; float top = 260f + row * 310f;
if (contains(left, top, left + cardW, top + cardH, x, y) && i < progress.unlockedCount()) { if (contains(left, top, left + cardW, top + cardH, x, y) && i < progress.unlockedCount()) {
performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
startLevel(i); startLevel(i);
return; 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) { private PointF toCanvasPoint(float x, float y) {
return new PointF((x - canvasOffsetX) / Math.max(0.001f, canvasScale), (y - canvasOffsetY) / Math.max(0.001f, canvasScale)); 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(); 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) { public void setAutomationInput(boolean moveLeft, boolean moveRight, boolean jump) {
boolean jumpWasPressed = jumpPressed; boolean jumpWasPressed = jumpPressed;
leftPressed = moveLeft; leftPressed = moveLeft;
@@ -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());
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ master tracks origin/master
- предметные задачи с acceptance criteria, приоритетами и зависимостями; - предметные задачи с acceptance criteria, приоритетами и зависимостями;
- уже подтверждённые кодом и QA элементы закрыты с причиной. - уже подтверждённые кодом и 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 завершённым. Текущий gate — `goon-game-milestone-vslice`. Инженерный кандидат этапа 2 собран; незакрытые gates включают `goon-game-human-playtest` и performance/repetition criteria `goon-game-regression-10`. Задачи этапа 3 можно начинать параллельно, не объявляя внешний playtest завершённым.
+5 -2
View File
@@ -6,7 +6,7 @@
## 1. Текущее состояние ## 1. Текущее состояние
Инженерная часть этапа 2 завершена и проект готов к параллельному старту этапа 3: Инженерная часть этапа 2 завершена; этап 3 начат с настроек мобильного управления:
- реализованы 10 последовательных уровней; - реализованы 10 последовательных уровней;
- работают движение, прыжок и мобильное multitouch-управление; - работают движение, прыжок и мобильное multitouch-управление;
@@ -18,6 +18,7 @@
- исправлено перекрытие `FREEZER` уровня 6, которое повторно убивало игрока на frozen stepping stone; - исправлено перекрытие `FREEZER` уровня 6, которое повторно убивало игрока на frozen stepping stone;
- тело, доставленное пилой на `PRESSURE` уровня 10, фиксируется после оседания и больше не соскальзывает с плиты; - тело, доставленное пилой на `PRESSURE` уровня 10, фиксируется после оседания и больше не соскальзывает с плиты;
- restart из промежуточного состояния уровня 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`; - final integrated Android 16 matrix: 16/16 методов, 36 игровых выполнений, lifecycle и restart; wall time `9:32.496`;
- финальный debug APK проверен на Android 16: установка, холодный запуск, ZIP-структура и v1/v2-подписи корректны. - финальный 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). Инженерный 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 нельзя называть принятым внешними тестировщиками.
+30
View File
@@ -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 этапа.