Добавить постоянную локализацию RU и EN
This commit is contained in:
@@ -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 android.view.View;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -162,6 +163,7 @@ public final class GameView extends View implements Runnable {
|
|||||||
private final ProgressStore progress;
|
private final ProgressStore progress;
|
||||||
private final ControlSettingsStore controlSettingsStore;
|
private final ControlSettingsStore controlSettingsStore;
|
||||||
private final OnboardingStore onboardingStore;
|
private final OnboardingStore onboardingStore;
|
||||||
|
private final GameLanguageStore languageStore;
|
||||||
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<>();
|
||||||
@@ -190,6 +192,8 @@ public final class GameView extends View implements Runnable {
|
|||||||
private ControlSettings controlSettings;
|
private ControlSettings controlSettings;
|
||||||
private OnboardingFlow onboardingFlow;
|
private OnboardingFlow onboardingFlow;
|
||||||
private CompletionResult completionResult;
|
private CompletionResult completionResult;
|
||||||
|
private GameLanguage language;
|
||||||
|
private GameStrings strings;
|
||||||
|
|
||||||
public GameView(Context context) {
|
public GameView(Context context) {
|
||||||
super(context);
|
super(context);
|
||||||
@@ -198,12 +202,15 @@ public final class GameView extends View implements Runnable {
|
|||||||
controlSettings = controlSettingsStore.load();
|
controlSettings = controlSettingsStore.load();
|
||||||
onboardingStore = new OnboardingStore(context);
|
onboardingStore = new OnboardingStore(context);
|
||||||
onboardingFlow = OnboardingFlow.firstRun(onboardingStore.isComplete());
|
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));
|
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);
|
||||||
setFocusable(true);
|
setFocusable(true);
|
||||||
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
|
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
|
||||||
setContentDescription("Project Sacrifice. Ten-level puzzle platform game.");
|
setContentDescription(strings.appContentDescription());
|
||||||
running = true;
|
running = true;
|
||||||
postOnAnimation(this);
|
postOnAnimation(this);
|
||||||
}
|
}
|
||||||
@@ -942,11 +949,15 @@ public final class GameView extends View implements Runnable {
|
|||||||
private void drawHud(Canvas canvas) {
|
private void drawHud(Canvas canvas) {
|
||||||
paint.setColor(Color.argb(178, 15, 20, 34));
|
paint.setColor(Color.argb(178, 15, 20, 34));
|
||||||
canvas.drawRoundRect(new RectF(28, 26, 940, 138), 24, 24, paint);
|
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, String.format(Locale.US, "%02d %s", level.number,
|
||||||
drawText(canvas, level.hint, 56, 116, 25, Color.rgb(231, 231, 235), false);
|
strings.levelTitle(level.number)), 56, 72, 34,
|
||||||
drawText(canvas, "Volunteers " + deaths + " Time " + GameMath.formatTime((long) (levelElapsed * 1000)), 1010, 82, 31, Color.WHITE, false);
|
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);
|
drawHudButton(canvas, new RectF(1790, 26, 1892, 112), "II", false);
|
||||||
|
|
||||||
float controlScale = controlSettings.controlScale();
|
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, 365, 925, 92 * controlScale, "›", rightPressed);
|
||||||
drawControlCircle(canvas, 1760, 900, 116 * controlScale, "▲", jumpPressed);
|
drawControlCircle(canvas, 1760, 900, 116 * controlScale, "▲", jumpPressed);
|
||||||
if (!player.alive) {
|
if (!player.alive) {
|
||||||
drawCenteredText(canvas, "Another volunteer is on the way…", VIEW_WIDTH * 0.5f, 235, 38, Color.rgb(250, 222, 153));
|
drawCenteredText(canvas, 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) {
|
private void drawLevelSelect(Canvas canvas) {
|
||||||
drawSky(canvas);
|
drawSky(canvas);
|
||||||
drawCenteredText(canvas, "PROJECT SACRIFICE", VIEW_WIDTH * 0.5f, 120, 64, Color.rgb(248, 212, 126));
|
drawCenteredText(canvas, strings.projectTitle(), VIEW_WIDTH * 0.5f, 120, 64,
|
||||||
drawCenteredText(canvas, "Every ending leaves a way forward", VIEW_WIDTH * 0.5f, 177, 29, Color.rgb(225, 226, 233));
|
Color.rgb(248, 212, 126));
|
||||||
|
drawCenteredText(canvas, strings.tagline(), VIEW_WIDTH * 0.5f, 177, 29,
|
||||||
|
Color.rgb(225, 226, 233));
|
||||||
int unlocked = progress.unlockedCount();
|
int unlocked = progress.unlockedCount();
|
||||||
float startX = 110f;
|
float startX = 110f;
|
||||||
float cardW = 310f;
|
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.setColor(complete ? Color.rgb(109, 222, 143) : (enabled ? Color.rgb(205, 164, 83) : Color.rgb(73, 77, 91)));
|
||||||
stroke.setStrokeWidth(5f);
|
stroke.setStrokeWidth(5f);
|
||||||
canvas.drawRoundRect(card, 22, 22, stroke);
|
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));
|
enabled ? Color.rgb(248, 210, 118) : Color.rgb(120, 123, 135));
|
||||||
LevelDefinition item = LevelCatalog.get(i);
|
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) {
|
if (complete) {
|
||||||
int bestDeaths = progress.bestDeaths(i + 1);
|
int bestDeaths = progress.bestDeaths(i + 1);
|
||||||
long bestTime = progress.bestTimeMs(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) {
|
} 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);
|
drawHudButton(canvas, new RectF(1560, 70, 1850, 160), strings.settingsButton(), false);
|
||||||
drawCenteredText(canvas, "Landscape · multitouch · progress saves automatically", VIEW_WIDTH * 0.5f, 980, 24, Color.argb(190, 230, 230, 236));
|
drawCenteredText(canvas, strings.levelSelectFooter(), VIEW_WIDTH * 0.5f, 980, 24,
|
||||||
|
Color.argb(190, 230, 230, 236));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void drawSettings(Canvas canvas) {
|
private void drawSettings(Canvas canvas) {
|
||||||
drawSky(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));
|
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));
|
VIEW_WIDTH * 0.5f, 205, 27, Color.rgb(225, 226, 233));
|
||||||
drawSettingRow(canvas, new RectF(420, 280, 1500, 410), "Control size",
|
drawText(canvas, strings.languageSetting(), 1_370f, 126f, 27f, Color.WHITE, true);
|
||||||
controlSettings.controlSizeLabel());
|
drawHudButton(canvas, new RectF(1580, 70, 1850, 160), strings.languageName(), false);
|
||||||
drawSettingRow(canvas, new RectF(420, 450, 1500, 580), "Control opacity",
|
drawSettingRow(canvas, new RectF(420, 280, 1500, 410), strings.controlSizeSetting(),
|
||||||
controlSettings.controlOpacityLabel());
|
strings.controlSizeValue(controlSettings.controlSizeIndex()));
|
||||||
drawSettingRow(canvas, new RectF(420, 620, 1500, 750), "Vibration",
|
drawSettingRow(canvas, new RectF(420, 450, 1500, 580), strings.controlOpacitySetting(),
|
||||||
controlSettings.hapticsLabel());
|
strings.controlOpacityValue(controlSettings.controlOpacityIndex()));
|
||||||
drawHudButton(canvas, new RectF(710, 850, 1210, 960), "Back", true);
|
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) {
|
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) {
|
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, 205, 64, Color.rgb(250, 211, 111));
|
drawCenteredText(canvas, strings.relicClaimed(), VIEW_WIDTH * 0.5f, 205, 64,
|
||||||
drawCenteredText(canvas, level.title, VIEW_WIDTH * 0.5f, 263, 34, Color.WHITE);
|
Color.rgb(250, 211, 111));
|
||||||
|
drawCenteredText(canvas, strings.levelTitle(level.number), VIEW_WIDTH * 0.5f, 263,
|
||||||
|
34, Color.WHITE);
|
||||||
|
|
||||||
CompletionResult result = completionResult;
|
CompletionResult result = completionResult;
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
drawCenteredText(canvas,
|
drawCenteredText(canvas, strings.currentResultLine(result.currentDeaths(),
|
||||||
"This run · " + result.currentDeaths() + " volunteers · "
|
result.currentTimeMs()),
|
||||||
+ GameMath.formatTime(result.currentTimeMs()),
|
|
||||||
VIEW_WIDTH * 0.5f, 380, 34, Color.WHITE);
|
VIEW_WIDTH * 0.5f, 380, 34, Color.WHITE);
|
||||||
drawCenteredText(canvas,
|
drawCenteredText(canvas, strings.bestResultLine(result.bestDeaths(),
|
||||||
"Saved best · " + result.bestDeaths() + " volunteers · "
|
result.bestTimeMs()),
|
||||||
+ GameMath.formatTime(result.bestTimeMs()),
|
|
||||||
VIEW_WIDTH * 0.5f, 437, 31, Color.rgb(128, 229, 153));
|
VIEW_WIDTH * 0.5f, 437, 31, Color.rgb(128, 229, 153));
|
||||||
drawCenteredText(canvas,
|
drawCenteredText(canvas, strings.targetsResultLine(result.targetDeaths(),
|
||||||
"Targets · " + result.targetDeaths() + " volunteers · "
|
result.targetTimeMs()),
|
||||||
+ GameMath.formatTime(result.targetTimeMs()),
|
|
||||||
VIEW_WIDTH * 0.5f, 510, 28, Color.rgb(210, 211, 218));
|
VIEW_WIDTH * 0.5f, 510, 28, Color.rgb(210, 211, 218));
|
||||||
drawCenteredText(canvas, nextGoalText(result), VIEW_WIDTH * 0.5f, 585, 31,
|
drawCenteredText(canvas, nextGoalText(result), VIEW_WIDTH * 0.5f, 585, 31,
|
||||||
Color.rgb(250, 211, 111));
|
Color.rgb(250, 211, 111));
|
||||||
}
|
}
|
||||||
|
|
||||||
drawHudButton(canvas, new RectF(345, 735, 755, 850), "Level select", false);
|
drawHudButton(canvas, new RectF(345, 735, 755, 850), strings.levelSelect(), false);
|
||||||
drawHudButton(canvas, new RectF(775, 735, 1145, 850), "Replay", 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), "Next level", true);
|
if (levelIndex + 1 < LevelCatalog.all().size()) {
|
||||||
|
drawHudButton(canvas, new RectF(1165, 735, 1575, 850), strings.nextLevel(), true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String nextGoalText(CompletionResult result) {
|
private String nextGoalText(CompletionResult result) {
|
||||||
switch (result.nextGoal()) {
|
switch (result.nextGoal()) {
|
||||||
case TARGET_DEATHS:
|
case TARGET_DEATHS:
|
||||||
return "Next goal · finish with " + result.nextGoalValue() + " volunteers";
|
return strings.nextGoalDeaths(result.nextGoalValue());
|
||||||
case TARGET_TIME:
|
case TARGET_TIME:
|
||||||
return "Next goal · finish in " + GameMath.formatTime(result.nextGoalValue());
|
return strings.nextGoalTime(result.nextGoalValue());
|
||||||
case NEXT_LEVEL:
|
case NEXT_LEVEL:
|
||||||
return "Next goal · enter level " + result.nextGoalValue();
|
return strings.nextGoalLevel(result.nextGoalValue());
|
||||||
case PERSONAL_BEST:
|
case PERSONAL_BEST:
|
||||||
default:
|
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;
|
String progressLabel;
|
||||||
switch (onboardingFlow.step()) {
|
switch (onboardingFlow.step()) {
|
||||||
case MOVEMENT:
|
case MOVEMENT:
|
||||||
title = "MOVE";
|
title = strings.tutorialMovementTitle();
|
||||||
message = "Hold the left or right arrow to explore.";
|
message = strings.tutorialMovementBody();
|
||||||
progressLabel = "1 / 3";
|
progressLabel = strings.tutorialProgress(1, 3);
|
||||||
break;
|
break;
|
||||||
case MULTITOUCH_JUMP:
|
case MULTITOUCH_JUMP:
|
||||||
title = "MOVE + JUMP";
|
title = strings.tutorialJumpTitle();
|
||||||
message = "Use two fingers: hold a direction and tap Jump.";
|
message = strings.tutorialJumpBody();
|
||||||
progressLabel = "2 / 3";
|
progressLabel = strings.tutorialProgress(2, 3);
|
||||||
break;
|
break;
|
||||||
case CORPSES_ARE_TOOLS:
|
case CORPSES_ARE_TOOLS:
|
||||||
default:
|
default:
|
||||||
title = "DEATH LEAVES A TOOL";
|
title = strings.tutorialCorpseTitle();
|
||||||
message = "A fallen volunteer remains. Use the body to open a path.";
|
message = strings.tutorialCorpseBody();
|
||||||
progressLabel = "3 / 3";
|
progressLabel = strings.tutorialProgress(3, 3);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
drawText(canvas, title, 575, 230, 30, Color.rgb(250, 211, 111), true);
|
drawFittedText(canvas, title, 575, 230, 30, 21, 740,
|
||||||
drawText(canvas, message, 575, 278, 27, Color.WHITE, false);
|
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);
|
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),
|
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);
|
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) {
|
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);
|
||||||
drawCenteredText(canvas, "PAUSED", VIEW_WIDTH * 0.5f, 350, 68, Color.rgb(248, 212, 126));
|
drawCenteredText(canvas, strings.paused(), VIEW_WIDTH * 0.5f, 350, 68,
|
||||||
drawHudButton(canvas, new RectF(610, 485, 1310, 610), "Resume", true);
|
Color.rgb(248, 212, 126));
|
||||||
drawHudButton(canvas, new RectF(610, 650, 1310, 775), "Level select", false);
|
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) {
|
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);
|
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) {
|
private void drawCenteredText(Canvas canvas, String text, float x, float baseline, float size, int color) {
|
||||||
paint.setShader(null);
|
paint.setShader(null);
|
||||||
paint.setStyle(Paint.Style.FILL);
|
paint.setStyle(Paint.Style.FILL);
|
||||||
@@ -1331,7 +1405,10 @@ public final class GameView extends View implements Runnable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void handleSettingsTap(float x, float y) {
|
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());
|
updateControlSettings(controlSettings.nextControlSize());
|
||||||
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
|
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
|
||||||
} else if (contains(420, 450, 1500, 580, x, y)) {
|
} else if (contains(420, 450, 1500, 580, x, y)) {
|
||||||
@@ -1458,6 +1535,98 @@ public final class GameView extends View implements Runnable {
|
|||||||
nextLevelFromResult();
|
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) {
|
private void updateControlSettings(ControlSettings updated) {
|
||||||
controlSettings = updated;
|
controlSettings = updated;
|
||||||
controlSettingsStore.save(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user