diff --git a/app/src/androidTest/java/com/projectsacrifice/game/GameLanguagePersistenceTest.java b/app/src/androidTest/java/com/projectsacrifice/game/GameLanguagePersistenceTest.java new file mode 100644 index 0000000..45bc1a9 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/GameLanguagePersistenceTest.java @@ -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 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 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(); + }); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameLanguage.java b/app/src/main/java/com/projectsacrifice/game/GameLanguage.java new file mode 100644 index 0000000..34188df --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/GameLanguage.java @@ -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; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameLanguageStore.java b/app/src/main/java/com/projectsacrifice/game/GameLanguageStore.java new file mode 100644 index 0000000..2585b00 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/GameLanguageStore.java @@ -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(); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameStrings.java b/app/src/main/java/com/projectsacrifice/game/GameStrings.java new file mode 100644 index 0000000..1791e85 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/GameStrings.java @@ -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; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index c61a37a..5919eaa 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -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 pointers = new SparseArray<>(); private final Player player = new Player(); private final ArrayList 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 visibleLocalizedTextForAutomation() { + ArrayList 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); diff --git a/app/src/test/java/com/projectsacrifice/game/GameStringsTest.java b/app/src/test/java/com/projectsacrifice/game/GameStringsTest.java new file mode 100644 index 0000000..8ff7583 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/GameStringsTest.java @@ -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)); + } + } +} diff --git a/docs/STAGE3_LOCALIZATION_QA.md b/docs/STAGE3_LOCALIZATION_QA.md new file mode 100644 index 0000000..e3580b5 --- /dev/null +++ b/docs/STAGE3_LOCALIZATION_QA.md @@ -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.