Начать этап 3 настройками управления
This commit is contained in:
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
+143
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 завершённым.
|
||||||
|
|
||||||
|
|||||||
@@ -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 нельзя называть принятым внешними тестировщиками.
|
||||||
|
|||||||
@@ -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 этапа.
|
||||||
Reference in New Issue
Block a user