Начать этап 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
+2 -2
View File
@@ -10,8 +10,8 @@ android {
applicationId 'com.projectsacrifice.game'
minSdk 23
targetSdk 34
versionCode 5
versionName '0.4.0-stage2-complete'
versionCode 6
versionName '0.5.0-stage3-controls'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
@@ -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 int MAX_CORPSES = 16;
private enum Screen { LEVELS, PLAYING, COMPLETE }
private enum Screen { LEVELS, SETTINGS, PLAYING, COMPLETE }
private enum DeathType { SPIKE, SAW, FREEZE, ELECTRIC, PIT }
public static final class AutomationSnapshot {
@@ -160,6 +160,7 @@ public final class GameView extends View implements Runnable {
private final LinearGradient skyGradient = new LinearGradient(
0, 0, 0, VIEW_HEIGHT, Color.rgb(20, 29, 55), Color.rgb(110, 81, 91), Shader.TileMode.CLAMP);
private final ProgressStore progress;
private final ControlSettingsStore controlSettingsStore;
private final SparseArray<PointF> pointers = new SparseArray<>();
private final Player player = new Player();
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 canvasOffsetX;
private float canvasOffsetY;
private ControlSettings controlSettings;
public GameView(Context context) {
super(context);
progress = new ProgressStore(context);
controlSettingsStore = new ControlSettingsStore(context);
controlSettings = controlSettingsStore.load();
paint.setTypeface(android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL));
stroke.setStyle(Paint.Style.STROKE);
stroke.setStrokeCap(Paint.Cap.ROUND);
@@ -227,6 +231,10 @@ public final class GameView extends View implements Runnable {
public boolean handleBack() {
if (screen == Screen.LEVELS) return false;
if (screen == Screen.SETTINGS) {
screen = Screen.LEVELS;
return true;
}
if (screen == Screen.COMPLETE) {
screen = Screen.LEVELS;
return true;
@@ -315,11 +323,11 @@ public final class GameView extends View implements Runnable {
boolean previous = Boolean.TRUE.equals(previousSwitchContact.get(contactKey));
if (item.type == LevelDefinition.SwitchType.TOGGLE && contact && !previous) {
toggleSignals.put(item.signalId, !Boolean.TRUE.equals(toggleSignals.get(item.signalId)));
performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
}
if (item.type == LevelDefinition.SwitchType.TIMED && contact && !previous) {
timedSignals.put(item.signalId, item.durationSeconds);
performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
}
previousSwitchContact.put(contactKey, contact);
}
@@ -404,7 +412,7 @@ public final class GameView extends View implements Runnable {
player.onGround = false;
player.coyote = 0f;
player.jumpBuffer = 0f;
performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
emitHaptic(HapticFeedbackConstants.CLOCK_TICK);
}
player.vy += GRAVITY * dt;
@@ -577,7 +585,7 @@ public final class GameView extends View implements Runnable {
player.supportIndex = -1;
deaths++;
respawnTimer = RESPAWN_SECONDS;
performHapticFeedback(Build.VERSION.SDK_INT >= 27
emitHaptic(Build.VERSION.SDK_INT >= 27
? HapticFeedbackConstants.LONG_PRESS : HapticFeedbackConstants.KEYBOARD_TAP);
if (type != DeathType.PIT) {
@@ -619,7 +627,7 @@ public final class GameView extends View implements Runnable {
screen = Screen.COMPLETE;
pointers.clear();
deriveControls();
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
emitHaptic(HapticFeedbackConstants.LONG_PRESS);
}
private RectF playerRect() {
@@ -650,6 +658,8 @@ public final class GameView extends View implements Runnable {
canvas.scale(canvasScale, canvasScale);
if (screen == Screen.LEVELS) {
drawLevelSelect(canvas);
} else if (screen == Screen.SETTINGS) {
drawSettings(canvas);
} else {
drawGame(canvas);
if (screen == Screen.COMPLETE) drawComplete(canvas);
@@ -917,21 +927,27 @@ public final class GameView extends View implements Runnable {
drawHudButton(canvas, new RectF(1608, 26, 1770, 112), "Restart", false);
drawHudButton(canvas, new RectF(1790, 26, 1892, 112), "II", false);
drawControlCircle(canvas, 145, 925, 92, "‹", leftPressed);
drawControlCircle(canvas, 365, 925, 92, "›", rightPressed);
drawControlCircle(canvas, 1760, 900, 116, "▲", jumpPressed);
float controlScale = controlSettings.controlScale();
drawControlCircle(canvas, 145, 925, 92 * controlScale, "‹", leftPressed);
drawControlCircle(canvas, 365, 925, 92 * controlScale, "›", rightPressed);
drawControlCircle(canvas, 1760, 900, 116 * controlScale, "▲", jumpPressed);
if (!player.alive) {
drawCenteredText(canvas, "Another volunteer is on the way…", VIEW_WIDTH * 0.5f, 235, 38, Color.rgb(250, 222, 153));
}
}
private void drawControlCircle(Canvas canvas, float x, float y, float radius, String label, boolean active) {
paint.setColor(active ? Color.argb(220, 245, 190, 74) : Color.argb(120, 22, 28, 43));
int inactiveFillAlpha = Math.round(190f * controlSettings.controlOpacity());
int inactiveStrokeAlpha = Math.round(230f * controlSettings.controlOpacity());
paint.setColor(active ? Color.argb(220, 245, 190, 74)
: Color.argb(inactiveFillAlpha, 22, 28, 43));
canvas.drawCircle(x, y, radius, paint);
stroke.setColor(active ? Color.rgb(255, 232, 169) : Color.argb(150, 240, 240, 245));
stroke.setColor(active ? Color.rgb(255, 232, 169)
: Color.argb(inactiveStrokeAlpha, 240, 240, 245));
stroke.setStrokeWidth(5f);
canvas.drawCircle(x, y, radius - 5, stroke);
drawCenteredText(canvas, label, x, y + 21, 68, active ? Color.rgb(34, 36, 45) : Color.WHITE);
drawCenteredText(canvas, label, x, y + 21, 68 * controlSettings.controlScale(),
active ? Color.rgb(34, 36, 45) : Color.WHITE);
}
private void drawLevelSelect(Canvas canvas) {
@@ -968,9 +984,36 @@ public final class GameView extends View implements Runnable {
drawCenteredText(canvas, "Tap to enter", card.centerX(), top + 190, 22, Color.rgb(202, 204, 214));
}
}
drawHudButton(canvas, new RectF(1560, 70, 1850, 160), "Settings", false);
drawCenteredText(canvas, "Landscape · multitouch · progress saves automatically", VIEW_WIDTH * 0.5f, 980, 24, Color.argb(190, 230, 230, 236));
}
private void drawSettings(Canvas canvas) {
drawSky(canvas);
drawCenteredText(canvas, "SETTINGS", VIEW_WIDTH * 0.5f, 145, 64,
Color.rgb(248, 212, 126));
drawCenteredText(canvas, "Tap a row to change it. Changes save automatically.",
VIEW_WIDTH * 0.5f, 205, 27, Color.rgb(225, 226, 233));
drawSettingRow(canvas, new RectF(420, 280, 1500, 410), "Control size",
controlSettings.controlSizeLabel());
drawSettingRow(canvas, new RectF(420, 450, 1500, 580), "Control opacity",
controlSettings.controlOpacityLabel());
drawSettingRow(canvas, new RectF(420, 620, 1500, 750), "Vibration",
controlSettings.hapticsLabel());
drawHudButton(canvas, new RectF(710, 850, 1210, 960), "Back", true);
}
private void drawSettingRow(Canvas canvas, RectF box, String label, String value) {
paint.setColor(Color.argb(220, 38, 45, 65));
canvas.drawRoundRect(box, 22, 22, paint);
stroke.setColor(Color.rgb(139, 145, 160));
stroke.setStrokeWidth(4f);
canvas.drawRoundRect(box, 22, 22, stroke);
drawText(canvas, label, box.left + 55f, box.centerY() + 11f, 32f, Color.WHITE, true);
drawHudButton(canvas, new RectF(1_090f, box.top + 24f, 1_445f, box.bottom - 24f),
value, false);
}
private void drawComplete(Canvas canvas) {
paint.setColor(Color.argb(218, 8, 12, 23));
canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint);
@@ -1047,6 +1090,13 @@ public final class GameView extends View implements Runnable {
}
return true;
}
if (screen == Screen.SETTINGS) {
if (action == MotionEvent.ACTION_UP) {
performClick();
handleSettingsTap(point.x, point.y);
}
return true;
}
if (screen == Screen.COMPLETE) {
if (action == MotionEvent.ACTION_UP) {
performClick();
@@ -1106,14 +1156,20 @@ public final class GameView extends View implements Runnable {
jumpPressed = false;
for (int i = 0; i < pointers.size(); i++) {
PointF point = pointers.valueAt(i);
if (distance(point.x, point.y, 145, 925) <= 115f) leftPressed = true;
if (distance(point.x, point.y, 365, 925) <= 115f) rightPressed = true;
if (distance(point.x, point.y, 1760, 900) <= 145f) jumpPressed = true;
float controlScale = controlSettings.controlScale();
if (distance(point.x, point.y, 145, 925) <= 115f * controlScale) leftPressed = true;
if (distance(point.x, point.y, 365, 925) <= 115f * controlScale) rightPressed = true;
if (distance(point.x, point.y, 1760, 900) <= 145f * controlScale) jumpPressed = true;
}
if (jumpPressed && !wasJumpPressed && player.alive) player.jumpBuffer = JUMP_BUFFER_SECONDS;
}
private void handleLevelSelectTap(float x, float y) {
if (contains(1560, 70, 1850, 160, x, y)) {
screen = Screen.SETTINGS;
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
return;
}
float startX = 110f;
float cardW = 310f;
float cardH = 250f;
@@ -1124,7 +1180,7 @@ public final class GameView extends View implements Runnable {
float left = startX + col * (cardW + gapX);
float top = 260f + row * 310f;
if (contains(left, top, left + cardW, top + cardH, x, y) && i < progress.unlockedCount()) {
performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
startLevel(i);
return;
}
@@ -1145,6 +1201,26 @@ public final class GameView extends View implements Runnable {
}
}
private void handleSettingsTap(float x, float y) {
if (contains(420, 280, 1500, 410, x, y)) {
updateControlSettings(controlSettings.nextControlSize());
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
} else if (contains(420, 450, 1500, 580, x, y)) {
updateControlSettings(controlSettings.nextControlOpacity());
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
} else if (contains(420, 620, 1500, 750, x, y)) {
updateControlSettings(controlSettings.toggleHaptics());
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
} else if (contains(710, 850, 1210, 960, x, y)) {
screen = Screen.LEVELS;
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
}
}
private void emitHaptic(int feedbackConstant) {
if (controlSettings.hapticsEnabled()) performHapticFeedback(feedbackConstant);
}
private PointF toCanvasPoint(float x, float y) {
return new PointF((x - canvasOffsetX) / Math.max(0.001f, canvasScale), (y - canvasOffsetY) / Math.max(0.001f, canvasScale));
}
@@ -1170,6 +1246,38 @@ public final class GameView extends View implements Runnable {
restartLevel();
}
public ControlSettings controlSettingsForAutomation() {
return controlSettings;
}
public boolean settingsVisibleForAutomation() {
return screen == Screen.SETTINGS;
}
public void resetControlSettingsForAutomation() {
updateControlSettings(ControlSettings.defaults());
}
public void selectNextControlSizeForAutomation() {
updateControlSettings(controlSettings.nextControlSize());
}
public void selectNextControlOpacityForAutomation() {
updateControlSettings(controlSettings.nextControlOpacity());
}
public void toggleHapticsForAutomation() {
updateControlSettings(controlSettings.toggleHaptics());
}
private void updateControlSettings(ControlSettings updated) {
controlSettings = updated;
controlSettingsStore.save(updated);
pointers.clear();
deriveControls();
invalidate();
}
public void setAutomationInput(boolean moveLeft, boolean moveRight, boolean jump) {
boolean jumpWasPressed = jumpPressed;
leftPressed = moveLeft;
@@ -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());
}
}