Добавить постоянную локализацию RU и EN

This commit is contained in:
2026-08-18 16:25:46 +03:00
parent 00323d7916
commit dcb16ef4d0
7 changed files with 721 additions and 58 deletions
@@ -0,0 +1,89 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.os.SystemClock;
import android.view.MotionEvent;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public final class GameLanguagePersistenceTest {
@Test
public void settingsLanguageSwitchSurvivesRecreationWithoutChangingProgressOrControls() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
int[] expectedUnlocked = new int[1];
int[] expectedBestDeaths = new int[1];
long[] expectedBestTime = new long[1];
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
view.resetLanguageForAutomation();
view.resetControlSettingsForAutomation();
view.selectNextControlSizeForAutomation();
view.selectNextControlOpacityForAutomation();
view.toggleHapticsForAutomation();
ProgressStore progress = new ProgressStore(current);
progress.recordCompletion(2, 4, 22_222L);
expectedUnlocked[0] = progress.unlockedCount();
expectedBestDeaths[0] = progress.bestDeaths(2);
expectedBestTime[0] = progress.bestTimeMs(2);
});
tap(activity, 1_700f, 115f); // Settings from level select.
activity.onActivity(current -> assertTrue(current.gameViewForAutomation()
.settingsVisibleForAutomation()));
tap(activity, 1_700f, 115f); // Language switch on Settings.
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
assertEquals(GameLanguage.RU, view.languageForAutomation());
assertTrue(view.visibleLocalizedTextForAutomation().contains("НАСТРОЙКИ"));
assertTrue(view.visibleLocalizedTextForAutomation().contains("Русский"));
});
activity.recreate();
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
assertEquals(GameLanguage.RU, view.languageForAutomation());
assertFalse(view.settingsVisibleForAutomation());
ControlSettings settings = view.controlSettingsForAutomation();
assertEquals("Large", settings.controlSizeLabel());
assertEquals("High", settings.controlOpacityLabel());
assertFalse(settings.hapticsEnabled());
ProgressStore progress = new ProgressStore(current);
assertEquals(expectedUnlocked[0], progress.unlockedCount());
assertEquals(expectedBestDeaths[0], progress.bestDeaths(2));
assertEquals(expectedBestTime[0], progress.bestTimeMs(2));
view.setLanguageForAutomation(GameLanguage.EN);
view.resetControlSettingsForAutomation();
});
}
}
private static void tap(ActivityScenario<MainActivity> activity, 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 down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0);
view.dispatchTouchEvent(down);
down.recycle();
MotionEvent up = MotionEvent.obtain(now, now, MotionEvent.ACTION_UP, x, y, 0);
view.dispatchTouchEvent(up);
up.recycle();
});
}
}
@@ -0,0 +1,26 @@
package com.projectsacrifice.game;
/** Player-selected language, independent from the device locale. */
public enum GameLanguage {
EN("en"),
RU("ru");
private final String code;
GameLanguage(String code) {
this.code = code;
}
public String code() {
return code;
}
public GameLanguage next() {
return this == EN ? RU : EN;
}
public static GameLanguage fromCode(String code) {
if (RU.code.equalsIgnoreCase(code)) return RU;
return EN;
}
}
@@ -0,0 +1,24 @@
package com.projectsacrifice.game;
import android.content.Context;
import android.content.SharedPreferences;
/** Persists the explicit in-game language without mutating progress or controls. */
final class GameLanguageStore {
private static final String FILE = "project_sacrifice_language_v1";
private static final String KEY_LANGUAGE = "language";
private final SharedPreferences preferences;
GameLanguageStore(Context context) {
preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE);
}
GameLanguage load() {
return GameLanguage.fromCode(preferences.getString(KEY_LANGUAGE, GameLanguage.EN.code()));
}
void save(GameLanguage language) {
preferences.edit().putString(KEY_LANGUAGE,
language == null ? GameLanguage.EN.code() : language.code()).apply();
}
}
@@ -0,0 +1,253 @@
package com.projectsacrifice.game;
/** All player-facing copy behind one typed, Android-independent interface. */
public final class GameStrings {
private static final String[] EN_TITLES = {
"The Volunteer", "A Useful End", "Dead Weight", "The Long Lift",
"Express Delivery", "Cold Comfort", "Live Wire", "Borrowed Time",
"Two Good Reasons", "The Last Procession"
};
private static final String[] RU_TITLES = {
"Рыцарь-доброволец", "Полезный конец", "Мёртвый груз", "Долгий подъём",
"Срочная доставка", "Холодное утешение", "Живой провод", "Время взаймы",
"Две веские причины", "Последнее шествие"
};
private static final String[] EN_HINTS = {
"Reach the relic. Arrows move; the winged button jumps.",
"The spikes keep every volunteer. Build a path from what remains.",
"Leave a body on the brass plate so the next volunteer can pass.",
"Touch the lever, then ride the moving platform to the upper gallery.",
"The saw throws to the right. Deliver a volunteer to the distant plate.",
"The frost makes a solid block. Use the frozen volunteer as a step.",
"A fallen volunteer conducts the current and keeps the relay alive.",
"Jump for the far blue relay if the timed gate closes.",
"The final gate needs weight and power at the same time.",
"Launch, freeze, conduct, ascend. Every lesson opens the last relic."
};
private static final String[] RU_HINTS = {
"Доберитесь до реликвии. Стрелки двигают; кнопка с крылом — прыжок.",
"Шипы сохраняют каждого добровольца. Проложите путь из того, что осталось.",
"Оставьте тело на латунной плите, чтобы следующий доброволец прошёл.",
"Коснитесь рычага, затем поднимитесь на платформе к верхней галерее.",
"Пила бросает вправо. Доставьте добровольца на дальнюю плиту.",
"Мороз создаёт твёрдый блок. Используйте замёрзшего добровольца как ступень.",
"Павший доброволец проводит ток и поддерживает реле.",
"Прыгните к дальнему синему реле, если ворота по таймеру закроются.",
"Последним воротам одновременно нужны вес и питание.",
"Разгонитесь, замёрзните, проведите ток, поднимитесь. Каждый урок открывает последнюю реликвию."
};
private final GameLanguage language;
private GameStrings(GameLanguage language) {
this.language = language == null ? GameLanguage.EN : language;
}
public static GameStrings forLanguage(GameLanguage language) {
return new GameStrings(language);
}
public GameLanguage language() {
return language;
}
private boolean russian() {
return language == GameLanguage.RU;
}
public String appContentDescription() {
return russian()
? "Project Sacrifice. Платформенная головоломка из десяти уровней."
: "Project Sacrifice. Ten-level puzzle platform game.";
}
public String projectTitle() { return "PROJECT SACRIFICE"; }
public String tagline() {
return russian() ? "Каждый конец оставляет путь вперёд"
: "Every ending leaves a way forward";
}
public String locked() { return russian() ? "ЗАКРЫТО" : "LOCKED"; }
public String tapToEnter() { return russian() ? "Нажмите, чтобы войти" : "Tap to enter"; }
public String levelSelectFooter() {
return russian()
? "Альбомная ориентация · мультитач · прогресс сохраняется автоматически"
: "Landscape · multitouch · progress saves automatically";
}
public String settingsButton() {
return russian() ? "Настройки" : "Settings";
}
public String settingsTitle() { return russian() ? "НАСТРОЙКИ" : "SETTINGS"; }
public String settingsInstruction() {
return russian() ? "Нажмите на строку, чтобы изменить. Всё сохраняется автоматически."
: "Tap a row to change it. Changes save automatically.";
}
public String controlSizeSetting() { return russian() ? "Размер управления" : "Control size"; }
public String controlOpacitySetting() { return russian() ? "Прозрачность управления" : "Control opacity"; }
public String vibrationSetting() { return russian() ? "Вибрация" : "Vibration"; }
public String languageSetting() { return russian() ? "Язык" : "Language"; }
public String languageName() { return russian() ? "Русский" : "English"; }
public String controlSizeValue(int index) {
String[] values = russian()
? new String[]{"Маленький", "Средний", "Большой"}
: new String[]{"Small", "Medium", "Large"};
return values[validPreset(index)];
}
public String controlOpacityValue(int index) {
String[] values = russian()
? new String[]{"Низкая", "Средняя", "Высокая"}
: new String[]{"Low", "Medium", "High"};
return values[validPreset(index)];
}
public String hapticsValue(boolean enabled) {
if (russian()) return enabled ? "Вкл." : "Выкл.";
return enabled ? "On" : "Off";
}
public String back() { return russian() ? "Назад" : "Back"; }
public String hudStats(int deaths, long elapsedMilliseconds) {
if (russian()) {
return "Добровольцы " + deaths + " Время " + GameMath.formatTime(elapsedMilliseconds);
}
return "Volunteers " + deaths + " Time " + GameMath.formatTime(elapsedMilliseconds);
}
public String restart() { return russian() ? "Заново" : "Restart"; }
public String respawning() {
return russian() ? "Следующий доброволец уже в пути…"
: "Another volunteer is on the way…";
}
public String levelBestStats(int deaths, long elapsedMilliseconds) {
return "✓ " + deaths + " " + volunteerWord(deaths) + " · "
+ GameMath.formatTime(elapsedMilliseconds);
}
public String relicClaimed() { return russian() ? "РЕЛИКВИЯ ПОЛУЧЕНА" : "RELIC CLAIMED"; }
public String completion() { return russian() ? "Завершение ★" : "Completion ★"; }
public String completionDeaths(boolean earned, int deaths, int targetDeaths) {
return (earned ? "★" : "☆") + " " + deaths + " / " + targetDeaths + " "
+ volunteerWord(deaths);
}
public String completionTime(boolean earned, long elapsedMilliseconds, long targetMilliseconds) {
return (earned ? "★" : "☆") + " " + GameMath.formatTime(elapsedMilliseconds)
+ " / " + GameMath.formatTime(targetMilliseconds);
}
public String levelSelect() { return russian() ? "Выбор уровня" : "Level select"; }
public String replay() { return russian() ? "Повторить" : "Replay"; }
public String nextLevel() { return russian() ? "Следующий уровень" : "Next level"; }
public String paused() { return russian() ? "ПАУЗА" : "PAUSED"; }
public String resume() { return russian() ? "Продолжить" : "Resume"; }
// Result-flow hooks used by the Stage 3 result screen.
public String currentResult() { return russian() ? "Текущий результат" : "Current result"; }
public String bestResult() { return russian() ? "Лучший результат" : "Best result"; }
public String deathsStat() { return russian() ? "Добровольцы" : "Volunteers"; }
public String timeStat() { return russian() ? "Время" : "Time"; }
public String newBest() { return russian() ? "НОВЫЙ РЕКОРД" : "NEW BEST"; }
public String currentResultLine(int deaths, long timeMs) {
return currentResult() + " · " + deaths + " " + volunteerWord(deaths)
+ " · " + GameMath.formatTime(timeMs);
}
public String bestResultLine(int deaths, long timeMs) {
return bestResult() + " · " + deaths + " " + volunteerWord(deaths)
+ " · " + GameMath.formatTime(timeMs);
}
public String targetsResultLine(int deaths, long timeMs) {
String label = russian() ? "Цели" : "Targets";
return label + " · " + deaths + " " + volunteerWord(deaths)
+ " · " + GameMath.formatTime(timeMs);
}
public String nextGoalDeaths(long deaths) {
return russian()
? "Следующая цель · пройти с " + deaths + " добровольцами"
: "Next goal · finish with " + deaths + " volunteers";
}
public String nextGoalTime(long timeMs) {
return (russian() ? "Следующая цель · пройти за " : "Next goal · finish in ")
+ GameMath.formatTime(timeMs);
}
public String nextGoalLevel(long level) {
return (russian() ? "Следующая цель · перейти на уровень "
: "Next goal · enter level ") + level;
}
public String nextGoalPersonalBest() {
return russian() ? "Следующая цель · улучшить личный рекорд"
: "Next goal · improve your personal best";
}
// Onboarding hooks keep new tutorial Canvas copy out of GameView conditionals.
public String tutorialWelcomeTitle() {
return russian() ? "Смерть — это инструмент" : "Death is a tool";
}
public String tutorialWelcomeBody() {
return russian()
? "Двигайтесь стрелками и прыгайте кнопкой с крылом. Доберитесь до реликвии."
: "Move with the arrows and jump with the winged button. Reach the relic.";
}
public String tutorialSacrificeTitle() {
return russian() ? "Оставьте путь вперёд" : "Leave a way forward";
}
public String tutorialSacrificeBody() {
return russian()
? "После смерти тело остаётся в мире: закрывает шипы, держит плиты и становится ступенью."
: "A body stays in the world after death: cover spikes, hold plates, and make a step.";
}
public String tutorialSkip() { return russian() ? "Пропустить" : "Skip"; }
public String tutorialNext() { return russian() ? "Далее" : "Next"; }
public String tutorialStart() { return russian() ? "Начать" : "Start"; }
public String tutorialDone() { return russian() ? "Готово" : "Done"; }
public String tutorialMovementTitle() { return russian() ? "ДВИЖЕНИЕ" : "MOVE"; }
public String tutorialMovementBody() {
return russian() ? "Удерживайте левую или правую стрелку, чтобы двигаться."
: "Hold the left or right arrow to explore.";
}
public String tutorialJumpTitle() { return russian() ? "ДВИЖЕНИЕ + ПРЫЖОК" : "MOVE + JUMP"; }
public String tutorialJumpBody() {
return russian() ? "Используйте два пальца: держите направление и нажмите Прыжок."
: "Use two fingers: hold a direction and tap Jump.";
}
public String tutorialCorpseTitle() {
return russian() ? "СМЕРТЬ ОСТАВЛЯЕТ ИНСТРУМЕНТ" : "DEATH LEAVES A TOOL";
}
public String tutorialCorpseBody() {
return russian() ? "Павший доброволец остаётся. Используйте тело, чтобы открыть путь."
: "A fallen volunteer remains. Use the body to open a path.";
}
public String tutorialProgress(int step, int total) {
return step + " / " + total;
}
public String levelTitle(int oneBasedLevelNumber) {
return (russian() ? RU_TITLES : EN_TITLES)[levelIndex(oneBasedLevelNumber)];
}
public String levelHint(int oneBasedLevelNumber) {
return (russian() ? RU_HINTS : EN_HINTS)[levelIndex(oneBasedLevelNumber)];
}
private String volunteerWord(int count) {
if (!russian()) return count == 1 ? "volunteer" : "volunteers";
int mod100 = Math.abs(count) % 100;
int mod10 = Math.abs(count) % 10;
if (mod100 >= 11 && mod100 <= 14) return "добровольцев";
if (mod10 == 1) return "доброволец";
if (mod10 >= 2 && mod10 <= 4) return "добровольца";
return "добровольцев";
}
private static int validPreset(int index) {
return index >= 0 && index <= 2 ? index : 1;
}
private static int levelIndex(int oneBasedLevelNumber) {
int index = oneBasedLevelNumber - 1;
return index >= 0 && index < EN_TITLES.length ? index : 0;
}
}
@@ -16,6 +16,7 @@ import android.view.MotionEvent;
import android.view.View;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
@@ -162,6 +163,7 @@ public final class GameView extends View implements Runnable {
private final ProgressStore progress;
private final ControlSettingsStore controlSettingsStore;
private final OnboardingStore onboardingStore;
private final GameLanguageStore languageStore;
private final SparseArray<PointF> pointers = new SparseArray<>();
private final Player player = new Player();
private final ArrayList<Corpse> corpses = new ArrayList<>();
@@ -190,6 +192,8 @@ public final class GameView extends View implements Runnable {
private ControlSettings controlSettings;
private OnboardingFlow onboardingFlow;
private CompletionResult completionResult;
private GameLanguage language;
private GameStrings strings;
public GameView(Context context) {
super(context);
@@ -198,12 +202,15 @@ public final class GameView extends View implements Runnable {
controlSettings = controlSettingsStore.load();
onboardingStore = new OnboardingStore(context);
onboardingFlow = OnboardingFlow.firstRun(onboardingStore.isComplete());
languageStore = new GameLanguageStore(context);
language = languageStore.load();
strings = GameStrings.forLanguage(language);
paint.setTypeface(android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL));
stroke.setStyle(Paint.Style.STROKE);
stroke.setStrokeCap(Paint.Cap.ROUND);
setFocusable(true);
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
setContentDescription("Project Sacrifice. Ten-level puzzle platform game.");
setContentDescription(strings.appContentDescription());
running = true;
postOnAnimation(this);
}
@@ -942,11 +949,15 @@ public final class GameView extends View implements Runnable {
private void drawHud(Canvas canvas) {
paint.setColor(Color.argb(178, 15, 20, 34));
canvas.drawRoundRect(new RectF(28, 26, 940, 138), 24, 24, paint);
drawText(canvas, String.format(Locale.US, "%02d %s", level.number, level.title), 56, 72, 34, Color.rgb(248, 220, 151), false);
drawText(canvas, level.hint, 56, 116, 25, Color.rgb(231, 231, 235), false);
drawText(canvas, "Volunteers " + deaths + " Time " + GameMath.formatTime((long) (levelElapsed * 1000)), 1010, 82, 31, Color.WHITE, false);
drawText(canvas, String.format(Locale.US, "%02d %s", level.number,
strings.levelTitle(level.number)), 56, 72, 34,
Color.rgb(248, 220, 151), false);
drawFittedText(canvas, strings.levelHint(level.number), 56, 116, 25, 16,
850, Color.rgb(231, 231, 235), false);
drawText(canvas, strings.hudStats(deaths, (long) (levelElapsed * 1000)),
1010, 82, 31, Color.WHITE, false);
drawHudButton(canvas, new RectF(1608, 26, 1770, 112), "Restart", false);
drawHudButton(canvas, new RectF(1608, 26, 1770, 112), strings.restart(), false);
drawHudButton(canvas, new RectF(1790, 26, 1892, 112), "II", false);
float controlScale = controlSettings.controlScale();
@@ -954,7 +965,8 @@ public final class GameView extends View implements Runnable {
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));
drawCenteredText(canvas, strings.respawning(), VIEW_WIDTH * 0.5f, 235, 38,
Color.rgb(250, 222, 153));
}
}
@@ -974,8 +986,10 @@ public final class GameView extends View implements Runnable {
private void drawLevelSelect(Canvas canvas) {
drawSky(canvas);
drawCenteredText(canvas, "PROJECT SACRIFICE", VIEW_WIDTH * 0.5f, 120, 64, Color.rgb(248, 212, 126));
drawCenteredText(canvas, "Every ending leaves a way forward", VIEW_WIDTH * 0.5f, 177, 29, Color.rgb(225, 226, 233));
drawCenteredText(canvas, strings.projectTitle(), VIEW_WIDTH * 0.5f, 120, 64,
Color.rgb(248, 212, 126));
drawCenteredText(canvas, strings.tagline(), VIEW_WIDTH * 0.5f, 177, 29,
Color.rgb(225, 226, 233));
int unlocked = progress.unlockedCount();
float startX = 110f;
float cardW = 310f;
@@ -994,35 +1008,43 @@ public final class GameView extends View implements Runnable {
stroke.setColor(complete ? Color.rgb(109, 222, 143) : (enabled ? Color.rgb(205, 164, 83) : Color.rgb(73, 77, 91)));
stroke.setStrokeWidth(5f);
canvas.drawRoundRect(card, 22, 22, stroke);
drawCenteredText(canvas, enabled ? String.format(Locale.US, "%02d", i + 1) : "LOCKED", card.centerX(), top + 72, enabled ? 48 : 27,
drawCenteredText(canvas, enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked(), card.centerX(), top + 72, enabled ? 48 : 27,
enabled ? Color.rgb(248, 210, 118) : Color.rgb(120, 123, 135));
LevelDefinition item = LevelCatalog.get(i);
drawCenteredText(canvas, item.title, card.centerX(), top + 124, 28, enabled ? Color.WHITE : Color.rgb(111, 114, 125));
drawCenteredFittedText(canvas, strings.levelTitle(item.number), card.centerX(),
top + 124, 28, 20, cardW - 28,
enabled ? Color.WHITE : Color.rgb(111, 114, 125));
if (complete) {
int bestDeaths = progress.bestDeaths(i + 1);
long bestTime = progress.bestTimeMs(i + 1);
drawCenteredText(canvas, "✓ " + bestDeaths + " volunteers · " + GameMath.formatTime(bestTime), card.centerX(), top + 185, 21, Color.rgb(138, 231, 163));
drawCenteredFittedText(canvas, strings.levelBestStats(bestDeaths, bestTime),
card.centerX(), top + 185, 21, 17, cardW - 24,
Color.rgb(138, 231, 163));
} else if (enabled) {
drawCenteredText(canvas, "Tap to enter", card.centerX(), top + 190, 22, Color.rgb(202, 204, 214));
drawCenteredText(canvas, strings.tapToEnter(), 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));
drawHudButton(canvas, new RectF(1560, 70, 1850, 160), strings.settingsButton(), false);
drawCenteredText(canvas, strings.levelSelectFooter(), 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,
drawCenteredText(canvas, strings.settingsTitle(), VIEW_WIDTH * 0.5f, 145, 64,
Color.rgb(248, 212, 126));
drawCenteredText(canvas, "Tap a row to change it. Changes save automatically.",
drawCenteredText(canvas, strings.settingsInstruction(),
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);
drawText(canvas, strings.languageSetting(), 1_370f, 126f, 27f, Color.WHITE, true);
drawHudButton(canvas, new RectF(1580, 70, 1850, 160), strings.languageName(), false);
drawSettingRow(canvas, new RectF(420, 280, 1500, 410), strings.controlSizeSetting(),
strings.controlSizeValue(controlSettings.controlSizeIndex()));
drawSettingRow(canvas, new RectF(420, 450, 1500, 580), strings.controlOpacitySetting(),
strings.controlOpacityValue(controlSettings.controlOpacityIndex()));
drawSettingRow(canvas, new RectF(420, 620, 1500, 750), strings.vibrationSetting(),
strings.hapticsValue(controlSettings.hapticsEnabled()));
drawHudButton(canvas, new RectF(710, 850, 1210, 960), strings.back(), true);
}
private void drawSettingRow(Canvas canvas, RectF box, String label, String value) {
@@ -1039,43 +1061,44 @@ public final class GameView extends View implements Runnable {
private void drawComplete(Canvas canvas) {
paint.setColor(Color.argb(218, 8, 12, 23));
canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint);
drawCenteredText(canvas, "RELIC CLAIMED", VIEW_WIDTH * 0.5f, 205, 64, Color.rgb(250, 211, 111));
drawCenteredText(canvas, level.title, VIEW_WIDTH * 0.5f, 263, 34, Color.WHITE);
drawCenteredText(canvas, strings.relicClaimed(), VIEW_WIDTH * 0.5f, 205, 64,
Color.rgb(250, 211, 111));
drawCenteredText(canvas, strings.levelTitle(level.number), VIEW_WIDTH * 0.5f, 263,
34, Color.WHITE);
CompletionResult result = completionResult;
if (result != null) {
drawCenteredText(canvas,
"This run · " + result.currentDeaths() + " volunteers · "
+ GameMath.formatTime(result.currentTimeMs()),
drawCenteredText(canvas, strings.currentResultLine(result.currentDeaths(),
result.currentTimeMs()),
VIEW_WIDTH * 0.5f, 380, 34, Color.WHITE);
drawCenteredText(canvas,
"Saved best · " + result.bestDeaths() + " volunteers · "
+ GameMath.formatTime(result.bestTimeMs()),
drawCenteredText(canvas, strings.bestResultLine(result.bestDeaths(),
result.bestTimeMs()),
VIEW_WIDTH * 0.5f, 437, 31, Color.rgb(128, 229, 153));
drawCenteredText(canvas,
"Targets · " + result.targetDeaths() + " volunteers · "
+ GameMath.formatTime(result.targetTimeMs()),
drawCenteredText(canvas, strings.targetsResultLine(result.targetDeaths(),
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(775, 735, 1145, 850), "Replay", false);
if (levelIndex + 1 < LevelCatalog.all().size()) drawHudButton(canvas, new RectF(1165, 735, 1575, 850), "Next level", true);
drawHudButton(canvas, new RectF(345, 735, 755, 850), strings.levelSelect(), false);
drawHudButton(canvas, new RectF(775, 735, 1145, 850), strings.replay(), false);
if (levelIndex + 1 < LevelCatalog.all().size()) {
drawHudButton(canvas, new RectF(1165, 735, 1575, 850), strings.nextLevel(), true);
}
}
private String nextGoalText(CompletionResult result) {
switch (result.nextGoal()) {
case TARGET_DEATHS:
return "Next goal · finish with " + result.nextGoalValue() + " volunteers";
return strings.nextGoalDeaths(result.nextGoalValue());
case TARGET_TIME:
return "Next goal · finish in " + GameMath.formatTime(result.nextGoalValue());
return strings.nextGoalTime(result.nextGoalValue());
case NEXT_LEVEL:
return "Next goal · enter level " + result.nextGoalValue();
return strings.nextGoalLevel(result.nextGoalValue());
case PERSONAL_BEST:
default:
return "Next goal · improve your personal best";
return strings.nextGoalPersonalBest();
}
}
@@ -1092,37 +1115,64 @@ public final class GameView extends View implements Runnable {
String progressLabel;
switch (onboardingFlow.step()) {
case MOVEMENT:
title = "MOVE";
message = "Hold the left or right arrow to explore.";
progressLabel = "1 / 3";
title = strings.tutorialMovementTitle();
message = strings.tutorialMovementBody();
progressLabel = strings.tutorialProgress(1, 3);
break;
case MULTITOUCH_JUMP:
title = "MOVE + JUMP";
message = "Use two fingers: hold a direction and tap Jump.";
progressLabel = "2 / 3";
title = strings.tutorialJumpTitle();
message = strings.tutorialJumpBody();
progressLabel = strings.tutorialProgress(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";
title = strings.tutorialCorpseTitle();
message = strings.tutorialCorpseBody();
progressLabel = strings.tutorialProgress(3, 3);
break;
}
drawText(canvas, title, 575, 230, 30, Color.rgb(250, 211, 111), true);
drawText(canvas, message, 575, 278, 27, Color.WHITE, false);
drawFittedText(canvas, title, 575, 230, 30, 21, 740,
Color.rgb(250, 211, 111), true);
drawFittedText(canvas, message, 575, 278, 27, 18, 740, 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(965, 298, 1125, 355), strings.tutorialSkip(), false);
drawHudButton(canvas, new RectF(1145, 298, 1345, 355),
onboardingFlow.step() == OnboardingFlow.Step.CORPSES_ARE_TOOLS ? "Done" : "Next",
onboardingFlow.step() == OnboardingFlow.Step.CORPSES_ARE_TOOLS
? strings.tutorialDone() : strings.tutorialNext(),
true);
}
private String onboardingTitle() {
switch (onboardingFlow.step()) {
case MOVEMENT:
return strings.tutorialMovementTitle();
case MULTITOUCH_JUMP:
return strings.tutorialJumpTitle();
case CORPSES_ARE_TOOLS:
default:
return strings.tutorialCorpseTitle();
}
}
private String onboardingBody() {
switch (onboardingFlow.step()) {
case MOVEMENT:
return strings.tutorialMovementBody();
case MULTITOUCH_JUMP:
return strings.tutorialJumpBody();
case CORPSES_ARE_TOOLS:
default:
return strings.tutorialCorpseBody();
}
}
private void drawPause(Canvas canvas) {
paint.setColor(Color.argb(218, 8, 12, 23));
canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint);
drawCenteredText(canvas, "PAUSED", VIEW_WIDTH * 0.5f, 350, 68, Color.rgb(248, 212, 126));
drawHudButton(canvas, new RectF(610, 485, 1310, 610), "Resume", true);
drawHudButton(canvas, new RectF(610, 650, 1310, 775), "Level select", false);
drawCenteredText(canvas, strings.paused(), VIEW_WIDTH * 0.5f, 350, 68,
Color.rgb(248, 212, 126));
drawHudButton(canvas, new RectF(610, 485, 1310, 610), strings.resume(), true);
drawHudButton(canvas, new RectF(610, 650, 1310, 775), strings.levelSelect(), false);
}
private void drawHudButton(Canvas canvas, RectF box, String label, boolean primary) {
@@ -1148,6 +1198,30 @@ public final class GameView extends View implements Runnable {
canvas.drawText(text, x, baseline, paint);
}
private void drawFittedText(Canvas canvas, String text, float x, float baseline,
float preferredSize, float minimumSize, float maximumWidth,
int color, boolean bold) {
float size = fittedTextSize(text, preferredSize, minimumSize, maximumWidth, bold);
drawText(canvas, text, x, baseline, size, color, bold);
}
private void drawCenteredFittedText(Canvas canvas, String text, float x, float baseline,
float preferredSize, float minimumSize,
float maximumWidth, int color) {
float size = fittedTextSize(text, preferredSize, minimumSize, maximumWidth, true);
drawCenteredText(canvas, text, x, baseline, size, color);
}
private float fittedTextSize(String text, float preferredSize, float minimumSize,
float maximumWidth, boolean bold) {
paint.setTextSize(preferredSize);
paint.setTypeface(android.graphics.Typeface.create("sans",
bold ? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL));
float measured = paint.measureText(text);
if (measured <= maximumWidth || measured <= 0f) return preferredSize;
return Math.max(minimumSize, preferredSize * maximumWidth / measured);
}
private void drawCenteredText(Canvas canvas, String text, float x, float baseline, float size, int color) {
paint.setShader(null);
paint.setStyle(Paint.Style.FILL);
@@ -1331,7 +1405,10 @@ public final class GameView extends View implements Runnable {
}
private void handleSettingsTap(float x, float y) {
if (contains(420, 280, 1500, 410, x, y)) {
if (contains(1580, 70, 1850, 160, x, y)) {
updateLanguage(language.next());
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
} else if (contains(420, 280, 1500, 410, x, y)) {
updateControlSettings(controlSettings.nextControlSize());
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
} else if (contains(420, 450, 1500, 580, x, y)) {
@@ -1458,6 +1535,98 @@ public final class GameView extends View implements Runnable {
nextLevelFromResult();
}
public GameLanguage languageForAutomation() {
return language;
}
public void resetLanguageForAutomation() {
updateLanguage(GameLanguage.EN);
}
public void selectNextLanguageForAutomation() {
updateLanguage(language.next());
}
public void setLanguageForAutomation(GameLanguage updated) {
updateLanguage(updated);
}
/** Exact localized copy sources that the current Canvas state renders. */
public List<String> visibleLocalizedTextForAutomation() {
ArrayList<String> visible = new ArrayList<>();
if (screen == Screen.LEVELS) {
visible.add(strings.projectTitle());
visible.add(strings.tagline());
for (int i = 0; i < LevelCatalog.all().size(); i++) {
boolean enabled = i < progress.unlockedCount();
visible.add(enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked());
visible.add(strings.levelTitle(i + 1));
if (progress.isCompleted(i + 1)) {
visible.add(strings.levelBestStats(progress.bestDeaths(i + 1),
progress.bestTimeMs(i + 1)));
} else if (enabled) {
visible.add(strings.tapToEnter());
}
}
visible.add(strings.settingsButton());
visible.add(strings.levelSelectFooter());
} else if (screen == Screen.SETTINGS) {
visible.add(strings.settingsTitle());
visible.add(strings.settingsInstruction());
visible.add(strings.languageSetting());
visible.add(strings.languageName());
visible.add(strings.controlSizeSetting());
visible.add(strings.controlSizeValue(controlSettings.controlSizeIndex()));
visible.add(strings.controlOpacitySetting());
visible.add(strings.controlOpacityValue(controlSettings.controlOpacityIndex()));
visible.add(strings.vibrationSetting());
visible.add(strings.hapticsValue(controlSettings.hapticsEnabled()));
visible.add(strings.back());
} else if (level != null) {
visible.add(String.format(Locale.US, "%02d %s", level.number,
strings.levelTitle(level.number)));
visible.add(strings.levelHint(level.number));
visible.add(strings.hudStats(deaths, (long) (levelElapsed * 1000)));
visible.add(strings.restart());
if (!player.alive) visible.add(strings.respawning());
if (screen == Screen.COMPLETE) {
visible.add(strings.relicClaimed());
visible.add(strings.levelTitle(level.number));
if (completionResult != null) {
visible.add(strings.currentResultLine(completionResult.currentDeaths(),
completionResult.currentTimeMs()));
visible.add(strings.bestResultLine(completionResult.bestDeaths(),
completionResult.bestTimeMs()));
visible.add(strings.targetsResultLine(completionResult.targetDeaths(),
completionResult.targetTimeMs()));
visible.add(nextGoalText(completionResult));
}
visible.add(strings.levelSelect());
visible.add(strings.replay());
if (levelIndex + 1 < LevelCatalog.all().size()) visible.add(strings.nextLevel());
} else if (userPaused) {
visible.add(strings.paused());
visible.add(strings.resume());
visible.add(strings.levelSelect());
} else if (onboardingFlow.isVisible()) {
visible.add(onboardingTitle());
visible.add(onboardingBody());
visible.add(strings.tutorialSkip());
visible.add(onboardingFlow.step() == OnboardingFlow.Step.CORPSES_ARE_TOOLS
? strings.tutorialDone() : strings.tutorialNext());
}
}
return Collections.unmodifiableList(visible);
}
private void updateLanguage(GameLanguage updated) {
language = updated == null ? GameLanguage.EN : updated;
strings = GameStrings.forLanguage(language);
languageStore.save(language);
setContentDescription(strings.appContentDescription());
invalidate();
}
private void updateControlSettings(ControlSettings updated) {
controlSettings = updated;
controlSettingsStore.save(updated);
@@ -0,0 +1,43 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import org.junit.Test;
public final class GameStringsTest {
@Test
public void missingOrUnknownLanguageSafelyFallsBackToEnglish() {
assertEquals(GameLanguage.EN, GameLanguage.fromCode(null));
assertEquals(GameLanguage.EN, GameLanguage.fromCode("de"));
GameStrings strings = GameStrings.forLanguage(GameLanguage.fromCode("de"));
assertEquals("Settings", strings.settingsButton());
assertEquals("The Volunteer", strings.levelTitle(1));
}
@Test
public void russianPackLocalizesTheCompletePlayerFacingContract() {
GameStrings english = GameStrings.forLanguage(GameLanguage.EN);
GameStrings russian = GameStrings.forLanguage(GameLanguage.RU);
assertEquals("Настройки", russian.settingsButton());
assertEquals("Размер управления", russian.controlSizeSetting());
assertEquals("Русский", russian.languageName());
assertEquals("Рыцарь-доброволец", russian.levelTitle(1));
assertEquals("Последнее шествие", russian.levelTitle(10));
assertEquals("Добровольцы 2 Время 00:07", russian.hudStats(2, 7_000L));
assertEquals("✓ 2 добровольца · 00:07", russian.levelBestStats(2, 7_000L));
assertEquals("★ 2 / 3 добровольца", russian.completionDeaths(true, 2, 3));
assertEquals("Смерть — это инструмент", russian.tutorialWelcomeTitle());
assertFalse(russian.tutorialSacrificeBody().trim().isEmpty());
for (int level = 1; level <= 10; level++) {
assertFalse(russian.levelTitle(level).trim().isEmpty());
assertFalse(russian.levelHint(level).trim().isEmpty());
assertNotEquals(english.levelTitle(level), russian.levelTitle(level));
assertNotEquals(english.levelHint(level), russian.levelHint(level));
}
}
}
+59
View File
@@ -0,0 +1,59 @@
# Stage 3 RU/EN localization QA
Date: 2026-08-18
Scope: persistent, explicit English/Russian language selection for the Canvas UI.
## Delivered contract
- English is the default for missing, null, or unknown stored language codes.
- The Settings screen exposes a dedicated English/Russian switch without moving the
existing control-size, opacity, vibration, or Back touch targets.
- The selected language is stored separately from progress and control preferences
and is restored when `MainActivity` is recreated.
- `GameStrings` owns all player-facing copy: level select, settings, HUD, respawn,
pause, completion, buttons, statistics, ten level titles and hints.
- Result-flow and onboarding copy has typed RU/EN hooks ready for the other Stage 3
slices; new Canvas code does not need language conditionals.
- Long localized hints, card titles, and best-result summaries are fitted to their
existing Canvas bounds. Gameplay geometry and Android `minSdk 23` are unchanged.
- Automation exposes current/set/toggle language and a read-only snapshot of the
localized copy rendered by the current screen.
## TDD evidence
1. RED: the fallback test failed compilation with seven missing `GameLanguage` /
`GameStrings` symbols. GREEN: English default and safe unknown/null fallback.
2. RED: the complete Russian-pack test failed compilation with ten missing copy
methods. GREEN: complete RU pack, ten titles/hints, statistics, plural forms,
result hooks, and onboarding hooks.
3. RED: the persistence device test failed compilation with four missing GameView
language hooks. GREEN: Settings toggle plus a dedicated preference store.
4. RED: the integrated acceptance extension failed compilation with three missing
setter/snapshot hooks. GREEN: explicit language setter and current-screen copy
snapshot.
## Automated checks
| Gate | Result |
| --- | --- |
| `testDebugUnitTest` | GREEN, 22/22 JVM tests |
| `lintDebug` | GREEN |
| `assembleDebug` | GREEN |
| `compileDebugAndroidTestJavaWithJavac` | GREEN, including `GameLanguagePersistenceTest` |
| Canvas raw-copy audit | GREEN; only language-neutral `II` and numeric formats remain in draw calls |
`GameLanguagePersistenceTest` is compiled here but intentionally not executed in
this isolated stream because emulator ownership belongs to final Stage 3 integration.
It performs real Settings taps and verifies Russian visible-copy snapshot, Activity
recreation, progress, control size, opacity, and vibration preservation.
## Integration notes
- `GameView.java` will conflict with accessibility/onboarding/result branches. Keep
their UI behavior and route every new visible string through the existing
`GameStrings` result/onboarding hooks.
- `ControlSettings.java` is not modified; localized preset labels are derived from
its stable package-visible preset indexes.
- `LevelCatalog.java` and level geometry are not modified. Canvas rendering now uses
`GameStrings.levelTitle/levelHint`; the existing English fields remain compatible
with gameplay tests and older callers.