Добавить обучение и полный экран результата

This commit is contained in:
2026-08-18 16:21:25 +03:00
parent 44067abde3
commit 00323d7916
9 changed files with 604 additions and 14 deletions
@@ -0,0 +1,86 @@
package com.projectsacrifice.game;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@RunWith(AndroidJUnit4.class)
public final class OnboardingAndResultFlowTest {
@Test
public void tutorialIsOptionalNonBlockingAndDoesNotRepeatAfterRecreation() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
activity.onActivity(owner -> {
GameView view = owner.gameViewForAutomation();
view.resetOnboardingForAutomation();
view.startLevelForAutomation(1);
assertTrue(view.onboardingVisibleForAutomation());
assertEquals(OnboardingFlow.Step.MOVEMENT,
view.onboardingStepForAutomation());
view.setAutomationInput(false, true, false);
assertTrue("Tutorial must not disable gameplay controls",
view.automationSnapshot().inputActive());
view.advanceOnboardingForAutomation();
view.advanceOnboardingForAutomation();
view.advanceOnboardingForAutomation();
assertFalse(view.onboardingVisibleForAutomation());
});
activity.recreate();
activity.onActivity(owner -> {
GameView view = owner.gameViewForAutomation();
view.startLevelForAutomation(1);
assertFalse(view.onboardingVisibleForAutomation());
});
}
}
@Test
public void completionShowsRunAndSavedBestAndAllResultRoutesPreserveState() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
activity.onActivity(owner -> {
GameView view = owner.gameViewForAutomation();
view.selectNextControlSizeForAutomation();
String selectedSize = view.controlSettingsForAutomation().controlSizeLabel();
view.startLevelForAutomation(1);
view.finishLevelForAutomation(2, 42_000L);
CompletionResult result = view.completionResultForAutomation();
assertNotNull(result);
assertTrue(view.resultVisibleForAutomation());
assertEquals(2, result.currentDeaths());
assertEquals(42_000L, result.currentTimeMs());
assertTrue(result.bestDeaths() <= result.currentDeaths());
assertTrue(result.bestTimeMs() <= result.currentTimeMs());
assertTrue(view.levelCompletedForAutomation(1));
view.replayFromResultForAutomation();
assertEquals(1, view.automationSnapshot().levelNumber());
assertFalse(view.automationSnapshot().completed());
assertEquals(selectedSize, view.controlSettingsForAutomation().controlSizeLabel());
assertTrue(view.levelCompletedForAutomation(1));
view.finishLevelForAutomation(1, 35_000L);
view.nextLevelFromResultForAutomation();
assertEquals(2, view.automationSnapshot().levelNumber());
assertEquals(selectedSize, view.controlSettingsForAutomation().controlSizeLabel());
assertTrue(view.levelCompletedForAutomation(1));
view.finishLevelForAutomation(1, 35_000L);
view.levelsFromResultForAutomation();
assertTrue(view.levelsVisibleForAutomation());
assertEquals(selectedSize, view.controlSettingsForAutomation().controlSizeLabel());
assertTrue(view.levelCompletedForAutomation(2));
view.resetControlSettingsForAutomation();
});
}
}
}
@@ -0,0 +1,28 @@
package com.projectsacrifice.game;
import android.content.Context;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@RunWith(AndroidJUnit4.class)
public final class OnboardingStoreTest {
@Test
public void dismissalSurvivesStoreRecreation() {
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
OnboardingStore first = new OnboardingStore(context);
first.resetForAutomation();
assertFalse(first.isComplete());
first.markComplete();
assertTrue(new OnboardingStore(context).isComplete());
}
}
@@ -0,0 +1,67 @@
package com.projectsacrifice.game;
/** Immutable completion statistics used by rendering and device-level navigation tests. */
public final class CompletionResult {
public enum NextGoal {
TARGET_DEATHS,
TARGET_TIME,
NEXT_LEVEL,
PERSONAL_BEST
}
private final int currentDeaths;
private final long currentTimeMs;
private final int bestDeaths;
private final long bestTimeMs;
private final int targetDeaths;
private final long targetTimeMs;
private final NextGoal nextGoal;
private final long nextGoalValue;
private CompletionResult(int currentDeaths, long currentTimeMs,
int bestDeaths, long bestTimeMs,
int targetDeaths, long targetTimeMs,
NextGoal nextGoal, long nextGoalValue) {
this.currentDeaths = currentDeaths;
this.currentTimeMs = currentTimeMs;
this.bestDeaths = bestDeaths;
this.bestTimeMs = bestTimeMs;
this.targetDeaths = targetDeaths;
this.targetTimeMs = targetTimeMs;
this.nextGoal = nextGoal;
this.nextGoalValue = nextGoalValue;
}
public static CompletionResult create(LevelDefinition level,
int currentDeaths, long currentTimeMs,
int bestDeaths, long bestTimeMs,
boolean hasNextLevel) {
long targetTimeMs = level.targetSeconds * 1_000L;
NextGoal goal;
long goalValue;
if (currentDeaths > level.targetDeaths) {
goal = NextGoal.TARGET_DEATHS;
goalValue = level.targetDeaths;
} else if (currentTimeMs > targetTimeMs) {
goal = NextGoal.TARGET_TIME;
goalValue = targetTimeMs;
} else if (hasNextLevel) {
goal = NextGoal.NEXT_LEVEL;
goalValue = level.number + 1L;
} else {
goal = NextGoal.PERSONAL_BEST;
goalValue = 0L;
}
return new CompletionResult(currentDeaths, currentTimeMs, bestDeaths, bestTimeMs,
level.targetDeaths, targetTimeMs, goal, goalValue);
}
public int currentDeaths() { return currentDeaths; }
public long currentTimeMs() { return currentTimeMs; }
public int bestDeaths() { return bestDeaths; }
public long bestTimeMs() { return bestTimeMs; }
public int targetDeaths() { return targetDeaths; }
public long targetTimeMs() { return targetTimeMs; }
public NextGoal nextGoal() { return nextGoal; }
public long nextGoalValue() { return nextGoalValue; }
}
@@ -161,6 +161,7 @@ public final class GameView extends View implements Runnable {
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 ControlSettingsStore controlSettingsStore;
private final OnboardingStore onboardingStore;
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<>();
@@ -187,12 +188,16 @@ public final class GameView extends View implements Runnable {
private float canvasOffsetX; private float canvasOffsetX;
private float canvasOffsetY; private float canvasOffsetY;
private ControlSettings controlSettings; private ControlSettings controlSettings;
private OnboardingFlow onboardingFlow;
private CompletionResult completionResult;
public GameView(Context context) { public GameView(Context context) {
super(context); super(context);
progress = new ProgressStore(context); progress = new ProgressStore(context);
controlSettingsStore = new ControlSettingsStore(context); controlSettingsStore = new ControlSettingsStore(context);
controlSettings = controlSettingsStore.load(); controlSettings = controlSettingsStore.load();
onboardingStore = new OnboardingStore(context);
onboardingFlow = OnboardingFlow.firstRun(onboardingStore.isComplete());
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);
@@ -236,7 +241,7 @@ public final class GameView extends View implements Runnable {
return true; return true;
} }
if (screen == Screen.COMPLETE) { if (screen == Screen.COMPLETE) {
screen = Screen.LEVELS; levelsFromResult();
return true; return true;
} }
if (userPaused) { if (userPaused) {
@@ -265,6 +270,7 @@ public final class GameView extends View implements Runnable {
toggleSignals.clear(); toggleSignals.clear();
timedSignals.clear(); timedSignals.clear();
previousSwitchContact.clear(); previousSwitchContact.clear();
completionResult = null;
for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item)); for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item));
spawnPlayer(); spawnPlayer();
lastFrameNanos = System.nanoTime(); lastFrameNanos = System.nanoTime();
@@ -622,7 +628,22 @@ public final class GameView extends View implements Runnable {
private void checkGoal() { private void checkGoal() {
if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, level.goal)) return; if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, level.goal)) return;
progress.recordCompletion(level.number, deaths, (long) (levelElapsed * 1000f)); finishLevel(deaths, (long) (levelElapsed * 1000f));
}
private void finishLevel(int completedDeaths, long completedTimeMs) {
long safeTimeMs = Math.max(0L, completedTimeMs);
deaths = Math.max(0, completedDeaths);
levelElapsed = safeTimeMs / 1_000f;
progress.recordCompletion(level.number, deaths, safeTimeMs);
completionResult = CompletionResult.create(
level,
deaths,
safeTimeMs,
progress.bestDeaths(level.number),
progress.bestTimeMs(level.number),
levelIndex + 1 < LevelCatalog.all().size()
);
player.vx = player.vy = 0f; player.vx = player.vy = 0f;
screen = Screen.COMPLETE; screen = Screen.COMPLETE;
pointers.clear(); pointers.clear();
@@ -664,6 +685,7 @@ public final class GameView extends View implements Runnable {
drawGame(canvas); drawGame(canvas);
if (screen == Screen.COMPLETE) drawComplete(canvas); if (screen == Screen.COMPLETE) drawComplete(canvas);
else if (userPaused) drawPause(canvas); else if (userPaused) drawPause(canvas);
else if (onboardingFlow.isVisible()) drawOnboarding(canvas);
} }
canvas.restore(); canvas.restore();
} }
@@ -1017,22 +1039,84 @@ public final class GameView extends View implements Runnable {
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);
drawCenteredText(canvas, "RELIC CLAIMED", VIEW_WIDTH * 0.5f, 270, 64, Color.rgb(250, 211, 111)); drawCenteredText(canvas, "RELIC CLAIMED", VIEW_WIDTH * 0.5f, 205, 64, Color.rgb(250, 211, 111));
drawCenteredText(canvas, level.title, VIEW_WIDTH * 0.5f, 335, 34, Color.WHITE); drawCenteredText(canvas, level.title, VIEW_WIDTH * 0.5f, 263, 34, Color.WHITE);
boolean deathStar = deaths <= level.targetDeaths; CompletionResult result = completionResult;
boolean timeStar = levelElapsed <= level.targetSeconds; if (result != null) {
drawCenteredText(canvas, "Completion ★", VIEW_WIDTH * 0.5f, 470, 38, Color.rgb(250, 211, 111)); drawCenteredText(canvas,
drawCenteredText(canvas, (deathStar ? "★" : "☆") + " " + deaths + " / " + level.targetDeaths + " volunteers", "This run · " + result.currentDeaths() + " volunteers · "
VIEW_WIDTH * 0.5f, 535, 31, deathStar ? Color.rgb(128, 229, 153) : Color.rgb(210, 211, 218)); + GameMath.formatTime(result.currentTimeMs()),
drawCenteredText(canvas, (timeStar ? "★" : "☆") + " " + GameMath.formatTime((long) (levelElapsed * 1000)) + " / " + GameMath.formatTime(level.targetSeconds * 1000L), VIEW_WIDTH * 0.5f, 380, 34, Color.WHITE);
VIEW_WIDTH * 0.5f, 588, 31, timeStar ? Color.rgb(128, 229, 153) : Color.rgb(210, 211, 218)); drawCenteredText(canvas,
"Saved best · " + result.bestDeaths() + " volunteers · "
+ GameMath.formatTime(result.bestTimeMs()),
VIEW_WIDTH * 0.5f, 437, 31, Color.rgb(128, 229, 153));
drawCenteredText(canvas,
"Targets · " + result.targetDeaths() + " volunteers · "
+ GameMath.formatTime(result.targetTimeMs()),
VIEW_WIDTH * 0.5f, 510, 28, Color.rgb(210, 211, 218));
drawCenteredText(canvas, nextGoalText(result), VIEW_WIDTH * 0.5f, 585, 31,
Color.rgb(250, 211, 111));
}
drawHudButton(canvas, new RectF(345, 735, 755, 850), "Level select", false); drawHudButton(canvas, new RectF(345, 735, 755, 850), "Level select", false);
drawHudButton(canvas, new RectF(775, 735, 1145, 850), "Replay", false); drawHudButton(canvas, new RectF(775, 735, 1145, 850), "Replay", false);
if (levelIndex + 1 < LevelCatalog.all().size()) drawHudButton(canvas, new RectF(1165, 735, 1575, 850), "Next level", true); if (levelIndex + 1 < LevelCatalog.all().size()) drawHudButton(canvas, new RectF(1165, 735, 1575, 850), "Next level", true);
} }
private String nextGoalText(CompletionResult result) {
switch (result.nextGoal()) {
case TARGET_DEATHS:
return "Next goal · finish with " + result.nextGoalValue() + " volunteers";
case TARGET_TIME:
return "Next goal · finish in " + GameMath.formatTime(result.nextGoalValue());
case NEXT_LEVEL:
return "Next goal · enter level " + result.nextGoalValue();
case PERSONAL_BEST:
default:
return "Next goal · improve your personal best";
}
}
private void drawOnboarding(Canvas canvas) {
RectF card = new RectF(535, 175, 1385, 375);
paint.setColor(Color.argb(232, 16, 22, 37));
canvas.drawRoundRect(card, 24, 24, paint);
stroke.setColor(Color.rgb(226, 169, 63));
stroke.setStrokeWidth(4f);
canvas.drawRoundRect(card, 24, 24, stroke);
String title;
String message;
String progressLabel;
switch (onboardingFlow.step()) {
case MOVEMENT:
title = "MOVE";
message = "Hold the left or right arrow to explore.";
progressLabel = "1 / 3";
break;
case MULTITOUCH_JUMP:
title = "MOVE + JUMP";
message = "Use two fingers: hold a direction and tap Jump.";
progressLabel = "2 / 3";
break;
case CORPSES_ARE_TOOLS:
default:
title = "DEATH LEAVES A TOOL";
message = "A fallen volunteer remains. Use the body to open a path.";
progressLabel = "3 / 3";
break;
}
drawText(canvas, title, 575, 230, 30, Color.rgb(250, 211, 111), true);
drawText(canvas, message, 575, 278, 27, Color.WHITE, false);
drawText(canvas, progressLabel, 575, 337, 22, Color.rgb(180, 185, 198), false);
drawHudButton(canvas, new RectF(965, 298, 1125, 355), "Skip", false);
drawHudButton(canvas, new RectF(1145, 298, 1345, 355),
onboardingFlow.step() == OnboardingFlow.Step.CORPSES_ARE_TOOLS ? "Done" : "Next",
true);
}
private void drawPause(Canvas canvas) { private void drawPause(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);
@@ -1112,6 +1196,14 @@ public final class GameView extends View implements Runnable {
return true; return true;
} }
if (onboardingFlow.isVisible() && action == MotionEvent.ACTION_UP
&& handleOnboardingTap(point.x, point.y)) {
performClick();
pointers.clear();
deriveControls();
return true;
}
if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN) { if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN) {
if (contains(1608, 26, 1770, 112, point.x, point.y)) { if (contains(1608, 26, 1770, 112, point.x, point.y)) {
restartLevel(); restartLevel();
@@ -1188,9 +1280,46 @@ public final class GameView extends View implements Runnable {
} }
private void handleCompleteTap(float x, float y) { private void handleCompleteTap(float x, float y) {
if (contains(345, 735, 755, 850, x, y)) screen = Screen.LEVELS; if (contains(345, 735, 755, 850, x, y)) levelsFromResult();
else if (contains(775, 735, 1145, 850, x, y)) restartLevel(); else if (contains(775, 735, 1145, 850, x, y)) replayFromResult();
else if (contains(1165, 735, 1575, 850, x, y) && levelIndex + 1 < LevelCatalog.all().size()) startLevel(levelIndex + 1); else if (contains(1165, 735, 1575, 850, x, y)) nextLevelFromResult();
}
private boolean handleOnboardingTap(float x, float y) {
if (contains(965, 298, 1125, 355, x, y)) {
onboardingFlow.skip();
onboardingStore.markComplete();
invalidate();
return true;
}
if (contains(1145, 298, 1345, 355, x, y)) {
advanceOnboarding();
return true;
}
return false;
}
private void advanceOnboarding() {
onboardingFlow.next();
if (onboardingFlow.isComplete()) onboardingStore.markComplete();
invalidate();
}
private void levelsFromResult() {
if (screen != Screen.COMPLETE) return;
screen = Screen.LEVELS;
pointers.clear();
deriveControls();
}
private void replayFromResult() {
if (screen == Screen.COMPLETE) restartLevel();
}
private void nextLevelFromResult() {
if (screen == Screen.COMPLETE && levelIndex + 1 < LevelCatalog.all().size()) {
startLevel(levelIndex + 1);
}
} }
private void handlePauseTap(float x, float y) { private void handlePauseTap(float x, float y) {
@@ -1254,6 +1383,34 @@ public final class GameView extends View implements Runnable {
return screen == Screen.SETTINGS; return screen == Screen.SETTINGS;
} }
public boolean levelsVisibleForAutomation() {
return screen == Screen.LEVELS;
}
public boolean onboardingVisibleForAutomation() {
return screen == Screen.PLAYING && onboardingFlow.isVisible();
}
public OnboardingFlow.Step onboardingStepForAutomation() {
return onboardingFlow.step();
}
public void resetOnboardingForAutomation() {
onboardingStore.resetForAutomation();
onboardingFlow = OnboardingFlow.firstRun(false);
invalidate();
}
public void advanceOnboardingForAutomation() {
advanceOnboarding();
}
public void skipOnboardingForAutomation() {
onboardingFlow.skip();
onboardingStore.markComplete();
invalidate();
}
public void resetControlSettingsForAutomation() { public void resetControlSettingsForAutomation() {
updateControlSettings(ControlSettings.defaults()); updateControlSettings(ControlSettings.defaults());
} }
@@ -1270,6 +1427,37 @@ public final class GameView extends View implements Runnable {
updateControlSettings(controlSettings.toggleHaptics()); updateControlSettings(controlSettings.toggleHaptics());
} }
public void finishLevelForAutomation(int completedDeaths, long completedTimeMs) {
if (screen != Screen.PLAYING || level == null) {
throw new IllegalStateException("A level must be playing before it can finish");
}
finishLevel(completedDeaths, completedTimeMs);
}
public CompletionResult completionResultForAutomation() {
return completionResult;
}
public boolean resultVisibleForAutomation() {
return screen == Screen.COMPLETE;
}
public boolean levelCompletedForAutomation(int oneBasedLevelNumber) {
return progress.isCompleted(oneBasedLevelNumber);
}
public void levelsFromResultForAutomation() {
levelsFromResult();
}
public void replayFromResultForAutomation() {
replayFromResult();
}
public void nextLevelFromResultForAutomation() {
nextLevelFromResult();
}
private void updateControlSettings(ControlSettings updated) { private void updateControlSettings(ControlSettings updated) {
controlSettings = updated; controlSettings = updated;
controlSettingsStore.save(updated); controlSettingsStore.save(updated);
@@ -0,0 +1,45 @@
package com.projectsacrifice.game;
/** Small state machine for the optional, first-run gameplay hints. */
public final class OnboardingFlow {
public enum Step {
MOVEMENT,
MULTITOUCH_JUMP,
CORPSES_ARE_TOOLS
}
private Step step;
private boolean complete;
private OnboardingFlow(boolean complete) {
this.complete = complete;
step = Step.MOVEMENT;
}
public static OnboardingFlow firstRun(boolean alreadyComplete) {
return new OnboardingFlow(alreadyComplete);
}
public boolean isVisible() {
return !complete;
}
public boolean isComplete() {
return complete;
}
public Step step() {
return step;
}
public void next() {
if (complete) return;
if (step == Step.MOVEMENT) step = Step.MULTITOUCH_JUMP;
else if (step == Step.MULTITOUCH_JUMP) step = Step.CORPSES_ARE_TOOLS;
else complete = true;
}
public void skip() {
complete = true;
}
}
@@ -0,0 +1,33 @@
package com.projectsacrifice.game;
import android.content.Context;
import android.content.SharedPreferences;
/** Versioned preference boundary for the optional first-run tutorial. */
public final class OnboardingStore {
private static final String FILE = "project_sacrifice_onboarding_v1";
private static final String KEY_COMPLETE = "complete";
private static final String KEY_SCHEMA = "schema";
private static final int SCHEMA_VERSION = 1;
private final SharedPreferences preferences;
public OnboardingStore(Context context) {
preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE);
}
public boolean isComplete() {
return preferences.getBoolean(KEY_COMPLETE, false);
}
public void markComplete() {
preferences.edit()
.putInt(KEY_SCHEMA, SCHEMA_VERSION)
.putBoolean(KEY_COMPLETE, true)
.apply();
}
public void resetForAutomation() {
preferences.edit().clear().commit();
}
}
@@ -0,0 +1,48 @@
package com.projectsacrifice.game;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
public final class CompletionResultTest {
@Test
public void resultKeepsThisRunAndSavedBestSeparateAndSuggestsTheMissingTarget() {
LevelDefinition level = LevelCatalog.get(0);
CompletionResult result = CompletionResult.create(
level,
2,
42_000L,
0,
25_000L,
true
);
assertEquals(2, result.currentDeaths());
assertEquals(42_000L, result.currentTimeMs());
assertEquals(0, result.bestDeaths());
assertEquals(25_000L, result.bestTimeMs());
assertEquals(CompletionResult.NextGoal.TARGET_DEATHS, result.nextGoal());
assertEquals(0, result.nextGoalValue());
}
@Test
public void masteredLevelPointsAtTheNextLevelOrPersonalBest() {
LevelDefinition first = LevelCatalog.get(0);
CompletionResult timeTarget = CompletionResult.create(first, 0, 31_000L,
0, 25_000L, true);
assertEquals(CompletionResult.NextGoal.TARGET_TIME, timeTarget.nextGoal());
assertEquals(30_000L, timeTarget.nextGoalValue());
CompletionResult next = CompletionResult.create(first, 0, 25_000L,
0, 25_000L, true);
assertEquals(CompletionResult.NextGoal.NEXT_LEVEL, next.nextGoal());
assertEquals(2, next.nextGoalValue());
LevelDefinition last = LevelCatalog.get(LevelCatalog.all().size() - 1);
CompletionResult lastResult = CompletionResult.create(last,
last.targetDeaths, last.targetSeconds * 900L,
last.targetDeaths, last.targetSeconds * 900L, false);
assertEquals(CompletionResult.NextGoal.PERSONAL_BEST, lastResult.nextGoal());
}
}
@@ -0,0 +1,38 @@
package com.projectsacrifice.game;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public final class OnboardingFlowTest {
@Test
public void firstRunExplainsControlsAndCorpsesThenStaysDismissed() {
OnboardingFlow flow = OnboardingFlow.firstRun(false);
assertTrue(flow.isVisible());
assertEquals(OnboardingFlow.Step.MOVEMENT, flow.step());
flow.next();
assertEquals(OnboardingFlow.Step.MULTITOUCH_JUMP, flow.step());
flow.next();
assertEquals(OnboardingFlow.Step.CORPSES_ARE_TOOLS, flow.step());
flow.next();
assertFalse(flow.isVisible());
assertTrue(flow.isComplete());
assertFalse(OnboardingFlow.firstRun(flow.isComplete()).isVisible());
}
@Test
public void firstRunCanBeSkippedImmediately() {
OnboardingFlow flow = OnboardingFlow.firstRun(false);
flow.skip();
assertFalse(flow.isVisible());
assertTrue(flow.isComplete());
}
}
+57
View File
@@ -0,0 +1,57 @@
# Stage 3 onboarding and result QA
Дата: 18 августа 2026 года. Базовая сборка: `0.5.0-stage3-controls-debug`
(`minSdk=23`). Этот документ описывает независимый slice обучения и результата;
версия финального Stage 3 APK будет назначена после интеграции всех веток.
## Реализовано
- при первом входе в уровень показывается компактная карточка из трёх подсказок:
движение, одновременные движение и прыжок двумя пальцами, использование тел;
- карточка не останавливает физику и не отключает экранное управление;
- доступны `Skip`, пошаговый `Next` и финальный `Done`;
- закрытие сохраняется в отдельном versioned storage
`project_sacrifice_onboarding_v1` и не изменяет progress/control settings;
- результат разделяет показатели текущего прохождения и сохранённые лучшие время
и число добровольцев;
- результат показывает оба target и выбирает понятную следующую цель: улучшить
число добровольцев, время, перейти на следующий уровень либо улучшить personal best;
- `Level select`, `Replay` и `Next level` используют общий navigation contract и
не очищают прогресс или настройки управления.
## TDD evidence
- `OnboardingFlowTest`: RED — `cannot find symbol: class OnboardingFlow`; GREEN — 2/2;
- `CompletionResultTest`: RED — `cannot find symbol: class CompletionResult`; GREEN — 2/2;
- `OnboardingStoreTest`: RED — `cannot find symbol: class OnboardingStore`; после
реализации AndroidTest compile GREEN;
- `OnboardingAndResultFlowTest`: RED — отсутствовали публичные GameView contracts
обучения/result navigation; после интеграции AndroidTest compile GREEN.
## Выполненные проверки
```text
./gradlew testDebugUnitTest lintDebug assembleDebug compileDebugAndroidTestJavaWithJavac
BUILD SUCCESSFUL
```
- JVM: 24/24, failures=0, errors=0;
- `lintDebug`: GREEN;
- `assembleDebug`: GREEN;
- все Android instrumentation sources: compile GREEN;
- `git diff --check`: GREEN.
По ограничению stream-задачи emulator/connected tests в этой ветке не запускались.
После интеграции необходимо выполнить `OnboardingStoreTest` и
`OnboardingAndResultFlowTest` на Android 16 вместе с полным Stage 2 regression gate.
## Приёмка на устройстве после интеграции
1. Очистить данные приложения, открыть уровень 1 и убедиться, что карточка не
мешает одновременно удерживать направление и нажимать Jump.
2. Проверить отдельно `Skip` и полный путь `Next` → `Next` → `Done`, затем cold
launch: обучение не должно появиться повторно.
3. Завершить уровень хуже сохранённого результата: `This run` не должен подменять
`Saved best`.
4. По очереди проверить `Level select`, `Replay`, `Next level`; завершённость
уровня, unlocked level и control preset должны сохраниться.