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

This commit is contained in:
2026-08-18 16:25:46 +03:00
parent 00323d7916
commit dcb16ef4d0
7 changed files with 721 additions and 58 deletions
@@ -16,6 +16,7 @@ import android.view.MotionEvent;
import android.view.View;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
@@ -162,6 +163,7 @@ public final class GameView extends View implements Runnable {
private final ProgressStore progress;
private final ControlSettingsStore controlSettingsStore;
private final OnboardingStore onboardingStore;
private final GameLanguageStore languageStore;
private final SparseArray<PointF> pointers = new SparseArray<>();
private final Player player = new Player();
private final ArrayList<Corpse> corpses = new ArrayList<>();
@@ -190,6 +192,8 @@ public final class GameView extends View implements Runnable {
private ControlSettings controlSettings;
private OnboardingFlow onboardingFlow;
private CompletionResult completionResult;
private GameLanguage language;
private GameStrings strings;
public GameView(Context context) {
super(context);
@@ -198,12 +202,15 @@ public final class GameView extends View implements Runnable {
controlSettings = controlSettingsStore.load();
onboardingStore = new OnboardingStore(context);
onboardingFlow = OnboardingFlow.firstRun(onboardingStore.isComplete());
languageStore = new GameLanguageStore(context);
language = languageStore.load();
strings = GameStrings.forLanguage(language);
paint.setTypeface(android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL));
stroke.setStyle(Paint.Style.STROKE);
stroke.setStrokeCap(Paint.Cap.ROUND);
setFocusable(true);
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
setContentDescription("Project Sacrifice. Ten-level puzzle platform game.");
setContentDescription(strings.appContentDescription());
running = true;
postOnAnimation(this);
}
@@ -942,11 +949,15 @@ public final class GameView extends View implements Runnable {
private void drawHud(Canvas canvas) {
paint.setColor(Color.argb(178, 15, 20, 34));
canvas.drawRoundRect(new RectF(28, 26, 940, 138), 24, 24, paint);
drawText(canvas, String.format(Locale.US, "%02d %s", level.number, level.title), 56, 72, 34, Color.rgb(248, 220, 151), false);
drawText(canvas, level.hint, 56, 116, 25, Color.rgb(231, 231, 235), false);
drawText(canvas, "Volunteers " + deaths + " Time " + GameMath.formatTime((long) (levelElapsed * 1000)), 1010, 82, 31, Color.WHITE, false);
drawText(canvas, String.format(Locale.US, "%02d %s", level.number,
strings.levelTitle(level.number)), 56, 72, 34,
Color.rgb(248, 220, 151), false);
drawFittedText(canvas, strings.levelHint(level.number), 56, 116, 25, 16,
850, Color.rgb(231, 231, 235), false);
drawText(canvas, strings.hudStats(deaths, (long) (levelElapsed * 1000)),
1010, 82, 31, Color.WHITE, false);
drawHudButton(canvas, new RectF(1608, 26, 1770, 112), "Restart", false);
drawHudButton(canvas, new RectF(1608, 26, 1770, 112), strings.restart(), false);
drawHudButton(canvas, new RectF(1790, 26, 1892, 112), "II", false);
float controlScale = controlSettings.controlScale();
@@ -954,7 +965,8 @@ public final class GameView extends View implements Runnable {
drawControlCircle(canvas, 365, 925, 92 * controlScale, "›", rightPressed);
drawControlCircle(canvas, 1760, 900, 116 * controlScale, "▲", jumpPressed);
if (!player.alive) {
drawCenteredText(canvas, "Another volunteer is on the way…", VIEW_WIDTH * 0.5f, 235, 38, Color.rgb(250, 222, 153));
drawCenteredText(canvas, strings.respawning(), VIEW_WIDTH * 0.5f, 235, 38,
Color.rgb(250, 222, 153));
}
}
@@ -974,8 +986,10 @@ public final class GameView extends View implements Runnable {
private void drawLevelSelect(Canvas canvas) {
drawSky(canvas);
drawCenteredText(canvas, "PROJECT SACRIFICE", VIEW_WIDTH * 0.5f, 120, 64, Color.rgb(248, 212, 126));
drawCenteredText(canvas, "Every ending leaves a way forward", VIEW_WIDTH * 0.5f, 177, 29, Color.rgb(225, 226, 233));
drawCenteredText(canvas, strings.projectTitle(), VIEW_WIDTH * 0.5f, 120, 64,
Color.rgb(248, 212, 126));
drawCenteredText(canvas, strings.tagline(), VIEW_WIDTH * 0.5f, 177, 29,
Color.rgb(225, 226, 233));
int unlocked = progress.unlockedCount();
float startX = 110f;
float cardW = 310f;
@@ -994,35 +1008,43 @@ public final class GameView extends View implements Runnable {
stroke.setColor(complete ? Color.rgb(109, 222, 143) : (enabled ? Color.rgb(205, 164, 83) : Color.rgb(73, 77, 91)));
stroke.setStrokeWidth(5f);
canvas.drawRoundRect(card, 22, 22, stroke);
drawCenteredText(canvas, enabled ? String.format(Locale.US, "%02d", i + 1) : "LOCKED", card.centerX(), top + 72, enabled ? 48 : 27,
drawCenteredText(canvas, enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked(), card.centerX(), top + 72, enabled ? 48 : 27,
enabled ? Color.rgb(248, 210, 118) : Color.rgb(120, 123, 135));
LevelDefinition item = LevelCatalog.get(i);
drawCenteredText(canvas, item.title, card.centerX(), top + 124, 28, enabled ? Color.WHITE : Color.rgb(111, 114, 125));
drawCenteredFittedText(canvas, strings.levelTitle(item.number), card.centerX(),
top + 124, 28, 20, cardW - 28,
enabled ? Color.WHITE : Color.rgb(111, 114, 125));
if (complete) {
int bestDeaths = progress.bestDeaths(i + 1);
long bestTime = progress.bestTimeMs(i + 1);
drawCenteredText(canvas, "✓ " + bestDeaths + " volunteers · " + GameMath.formatTime(bestTime), card.centerX(), top + 185, 21, Color.rgb(138, 231, 163));
drawCenteredFittedText(canvas, strings.levelBestStats(bestDeaths, bestTime),
card.centerX(), top + 185, 21, 17, cardW - 24,
Color.rgb(138, 231, 163));
} else if (enabled) {
drawCenteredText(canvas, "Tap to enter", card.centerX(), top + 190, 22, Color.rgb(202, 204, 214));
drawCenteredText(canvas, strings.tapToEnter(), card.centerX(), top + 190,
22, Color.rgb(202, 204, 214));
}
}
drawHudButton(canvas, new RectF(1560, 70, 1850, 160), "Settings", false);
drawCenteredText(canvas, "Landscape · multitouch · progress saves automatically", VIEW_WIDTH * 0.5f, 980, 24, Color.argb(190, 230, 230, 236));
drawHudButton(canvas, new RectF(1560, 70, 1850, 160), strings.settingsButton(), false);
drawCenteredText(canvas, strings.levelSelectFooter(), VIEW_WIDTH * 0.5f, 980, 24,
Color.argb(190, 230, 230, 236));
}
private void drawSettings(Canvas canvas) {
drawSky(canvas);
drawCenteredText(canvas, "SETTINGS", VIEW_WIDTH * 0.5f, 145, 64,
drawCenteredText(canvas, strings.settingsTitle(), VIEW_WIDTH * 0.5f, 145, 64,
Color.rgb(248, 212, 126));
drawCenteredText(canvas, "Tap a row to change it. Changes save automatically.",
drawCenteredText(canvas, strings.settingsInstruction(),
VIEW_WIDTH * 0.5f, 205, 27, Color.rgb(225, 226, 233));
drawSettingRow(canvas, new RectF(420, 280, 1500, 410), "Control size",
controlSettings.controlSizeLabel());
drawSettingRow(canvas, new RectF(420, 450, 1500, 580), "Control opacity",
controlSettings.controlOpacityLabel());
drawSettingRow(canvas, new RectF(420, 620, 1500, 750), "Vibration",
controlSettings.hapticsLabel());
drawHudButton(canvas, new RectF(710, 850, 1210, 960), "Back", true);
drawText(canvas, strings.languageSetting(), 1_370f, 126f, 27f, Color.WHITE, true);
drawHudButton(canvas, new RectF(1580, 70, 1850, 160), strings.languageName(), false);
drawSettingRow(canvas, new RectF(420, 280, 1500, 410), strings.controlSizeSetting(),
strings.controlSizeValue(controlSettings.controlSizeIndex()));
drawSettingRow(canvas, new RectF(420, 450, 1500, 580), strings.controlOpacitySetting(),
strings.controlOpacityValue(controlSettings.controlOpacityIndex()));
drawSettingRow(canvas, new RectF(420, 620, 1500, 750), strings.vibrationSetting(),
strings.hapticsValue(controlSettings.hapticsEnabled()));
drawHudButton(canvas, new RectF(710, 850, 1210, 960), strings.back(), true);
}
private void drawSettingRow(Canvas canvas, RectF box, String label, String value) {
@@ -1039,43 +1061,44 @@ public final class GameView extends View implements Runnable {
private void drawComplete(Canvas canvas) {
paint.setColor(Color.argb(218, 8, 12, 23));
canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint);
drawCenteredText(canvas, "RELIC CLAIMED", VIEW_WIDTH * 0.5f, 205, 64, Color.rgb(250, 211, 111));
drawCenteredText(canvas, level.title, VIEW_WIDTH * 0.5f, 263, 34, Color.WHITE);
drawCenteredText(canvas, strings.relicClaimed(), VIEW_WIDTH * 0.5f, 205, 64,
Color.rgb(250, 211, 111));
drawCenteredText(canvas, strings.levelTitle(level.number), VIEW_WIDTH * 0.5f, 263,
34, Color.WHITE);
CompletionResult result = completionResult;
if (result != null) {
drawCenteredText(canvas,
"This run · " + result.currentDeaths() + " volunteers · "
+ GameMath.formatTime(result.currentTimeMs()),
drawCenteredText(canvas, strings.currentResultLine(result.currentDeaths(),
result.currentTimeMs()),
VIEW_WIDTH * 0.5f, 380, 34, Color.WHITE);
drawCenteredText(canvas,
"Saved best · " + result.bestDeaths() + " volunteers · "
+ GameMath.formatTime(result.bestTimeMs()),
drawCenteredText(canvas, strings.bestResultLine(result.bestDeaths(),
result.bestTimeMs()),
VIEW_WIDTH * 0.5f, 437, 31, Color.rgb(128, 229, 153));
drawCenteredText(canvas,
"Targets · " + result.targetDeaths() + " volunteers · "
+ GameMath.formatTime(result.targetTimeMs()),
drawCenteredText(canvas, strings.targetsResultLine(result.targetDeaths(),
result.targetTimeMs()),
VIEW_WIDTH * 0.5f, 510, 28, Color.rgb(210, 211, 218));
drawCenteredText(canvas, nextGoalText(result), VIEW_WIDTH * 0.5f, 585, 31,
Color.rgb(250, 211, 111));
}
drawHudButton(canvas, new RectF(345, 735, 755, 850), "Level select", false);
drawHudButton(canvas, new RectF(775, 735, 1145, 850), "Replay", false);
if (levelIndex + 1 < LevelCatalog.all().size()) drawHudButton(canvas, new RectF(1165, 735, 1575, 850), "Next level", true);
drawHudButton(canvas, new RectF(345, 735, 755, 850), strings.levelSelect(), false);
drawHudButton(canvas, new RectF(775, 735, 1145, 850), strings.replay(), false);
if (levelIndex + 1 < LevelCatalog.all().size()) {
drawHudButton(canvas, new RectF(1165, 735, 1575, 850), strings.nextLevel(), true);
}
}
private String nextGoalText(CompletionResult result) {
switch (result.nextGoal()) {
case TARGET_DEATHS:
return "Next goal · finish with " + result.nextGoalValue() + " volunteers";
return strings.nextGoalDeaths(result.nextGoalValue());
case TARGET_TIME:
return "Next goal · finish in " + GameMath.formatTime(result.nextGoalValue());
return strings.nextGoalTime(result.nextGoalValue());
case NEXT_LEVEL:
return "Next goal · enter level " + result.nextGoalValue();
return strings.nextGoalLevel(result.nextGoalValue());
case PERSONAL_BEST:
default:
return "Next goal · improve your personal best";
return strings.nextGoalPersonalBest();
}
}
@@ -1092,37 +1115,64 @@ public final class GameView extends View implements Runnable {
String progressLabel;
switch (onboardingFlow.step()) {
case MOVEMENT:
title = "MOVE";
message = "Hold the left or right arrow to explore.";
progressLabel = "1 / 3";
title = strings.tutorialMovementTitle();
message = strings.tutorialMovementBody();
progressLabel = strings.tutorialProgress(1, 3);
break;
case MULTITOUCH_JUMP:
title = "MOVE + JUMP";
message = "Use two fingers: hold a direction and tap Jump.";
progressLabel = "2 / 3";
title = strings.tutorialJumpTitle();
message = strings.tutorialJumpBody();
progressLabel = strings.tutorialProgress(2, 3);
break;
case CORPSES_ARE_TOOLS:
default:
title = "DEATH LEAVES A TOOL";
message = "A fallen volunteer remains. Use the body to open a path.";
progressLabel = "3 / 3";
title = strings.tutorialCorpseTitle();
message = strings.tutorialCorpseBody();
progressLabel = strings.tutorialProgress(3, 3);
break;
}
drawText(canvas, title, 575, 230, 30, Color.rgb(250, 211, 111), true);
drawText(canvas, message, 575, 278, 27, Color.WHITE, false);
drawFittedText(canvas, title, 575, 230, 30, 21, 740,
Color.rgb(250, 211, 111), true);
drawFittedText(canvas, message, 575, 278, 27, 18, 740, Color.WHITE, false);
drawText(canvas, progressLabel, 575, 337, 22, Color.rgb(180, 185, 198), false);
drawHudButton(canvas, new RectF(965, 298, 1125, 355), "Skip", false);
drawHudButton(canvas, new RectF(965, 298, 1125, 355), strings.tutorialSkip(), false);
drawHudButton(canvas, new RectF(1145, 298, 1345, 355),
onboardingFlow.step() == OnboardingFlow.Step.CORPSES_ARE_TOOLS ? "Done" : "Next",
onboardingFlow.step() == OnboardingFlow.Step.CORPSES_ARE_TOOLS
? strings.tutorialDone() : strings.tutorialNext(),
true);
}
private String onboardingTitle() {
switch (onboardingFlow.step()) {
case MOVEMENT:
return strings.tutorialMovementTitle();
case MULTITOUCH_JUMP:
return strings.tutorialJumpTitle();
case CORPSES_ARE_TOOLS:
default:
return strings.tutorialCorpseTitle();
}
}
private String onboardingBody() {
switch (onboardingFlow.step()) {
case MOVEMENT:
return strings.tutorialMovementBody();
case MULTITOUCH_JUMP:
return strings.tutorialJumpBody();
case CORPSES_ARE_TOOLS:
default:
return strings.tutorialCorpseBody();
}
}
private void drawPause(Canvas canvas) {
paint.setColor(Color.argb(218, 8, 12, 23));
canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint);
drawCenteredText(canvas, "PAUSED", VIEW_WIDTH * 0.5f, 350, 68, Color.rgb(248, 212, 126));
drawHudButton(canvas, new RectF(610, 485, 1310, 610), "Resume", true);
drawHudButton(canvas, new RectF(610, 650, 1310, 775), "Level select", false);
drawCenteredText(canvas, strings.paused(), VIEW_WIDTH * 0.5f, 350, 68,
Color.rgb(248, 212, 126));
drawHudButton(canvas, new RectF(610, 485, 1310, 610), strings.resume(), true);
drawHudButton(canvas, new RectF(610, 650, 1310, 775), strings.levelSelect(), false);
}
private void drawHudButton(Canvas canvas, RectF box, String label, boolean primary) {
@@ -1148,6 +1198,30 @@ public final class GameView extends View implements Runnable {
canvas.drawText(text, x, baseline, paint);
}
private void drawFittedText(Canvas canvas, String text, float x, float baseline,
float preferredSize, float minimumSize, float maximumWidth,
int color, boolean bold) {
float size = fittedTextSize(text, preferredSize, minimumSize, maximumWidth, bold);
drawText(canvas, text, x, baseline, size, color, bold);
}
private void drawCenteredFittedText(Canvas canvas, String text, float x, float baseline,
float preferredSize, float minimumSize,
float maximumWidth, int color) {
float size = fittedTextSize(text, preferredSize, minimumSize, maximumWidth, true);
drawCenteredText(canvas, text, x, baseline, size, color);
}
private float fittedTextSize(String text, float preferredSize, float minimumSize,
float maximumWidth, boolean bold) {
paint.setTextSize(preferredSize);
paint.setTypeface(android.graphics.Typeface.create("sans",
bold ? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL));
float measured = paint.measureText(text);
if (measured <= maximumWidth || measured <= 0f) return preferredSize;
return Math.max(minimumSize, preferredSize * maximumWidth / measured);
}
private void drawCenteredText(Canvas canvas, String text, float x, float baseline, float size, int color) {
paint.setShader(null);
paint.setStyle(Paint.Style.FILL);
@@ -1331,7 +1405,10 @@ public final class GameView extends View implements Runnable {
}
private void handleSettingsTap(float x, float y) {
if (contains(420, 280, 1500, 410, x, y)) {
if (contains(1580, 70, 1850, 160, x, y)) {
updateLanguage(language.next());
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
} else if (contains(420, 280, 1500, 410, x, y)) {
updateControlSettings(controlSettings.nextControlSize());
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
} else if (contains(420, 450, 1500, 580, x, y)) {
@@ -1458,6 +1535,98 @@ public final class GameView extends View implements Runnable {
nextLevelFromResult();
}
public GameLanguage languageForAutomation() {
return language;
}
public void resetLanguageForAutomation() {
updateLanguage(GameLanguage.EN);
}
public void selectNextLanguageForAutomation() {
updateLanguage(language.next());
}
public void setLanguageForAutomation(GameLanguage updated) {
updateLanguage(updated);
}
/** Exact localized copy sources that the current Canvas state renders. */
public List<String> visibleLocalizedTextForAutomation() {
ArrayList<String> visible = new ArrayList<>();
if (screen == Screen.LEVELS) {
visible.add(strings.projectTitle());
visible.add(strings.tagline());
for (int i = 0; i < LevelCatalog.all().size(); i++) {
boolean enabled = i < progress.unlockedCount();
visible.add(enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked());
visible.add(strings.levelTitle(i + 1));
if (progress.isCompleted(i + 1)) {
visible.add(strings.levelBestStats(progress.bestDeaths(i + 1),
progress.bestTimeMs(i + 1)));
} else if (enabled) {
visible.add(strings.tapToEnter());
}
}
visible.add(strings.settingsButton());
visible.add(strings.levelSelectFooter());
} else if (screen == Screen.SETTINGS) {
visible.add(strings.settingsTitle());
visible.add(strings.settingsInstruction());
visible.add(strings.languageSetting());
visible.add(strings.languageName());
visible.add(strings.controlSizeSetting());
visible.add(strings.controlSizeValue(controlSettings.controlSizeIndex()));
visible.add(strings.controlOpacitySetting());
visible.add(strings.controlOpacityValue(controlSettings.controlOpacityIndex()));
visible.add(strings.vibrationSetting());
visible.add(strings.hapticsValue(controlSettings.hapticsEnabled()));
visible.add(strings.back());
} else if (level != null) {
visible.add(String.format(Locale.US, "%02d %s", level.number,
strings.levelTitle(level.number)));
visible.add(strings.levelHint(level.number));
visible.add(strings.hudStats(deaths, (long) (levelElapsed * 1000)));
visible.add(strings.restart());
if (!player.alive) visible.add(strings.respawning());
if (screen == Screen.COMPLETE) {
visible.add(strings.relicClaimed());
visible.add(strings.levelTitle(level.number));
if (completionResult != null) {
visible.add(strings.currentResultLine(completionResult.currentDeaths(),
completionResult.currentTimeMs()));
visible.add(strings.bestResultLine(completionResult.bestDeaths(),
completionResult.bestTimeMs()));
visible.add(strings.targetsResultLine(completionResult.targetDeaths(),
completionResult.targetTimeMs()));
visible.add(nextGoalText(completionResult));
}
visible.add(strings.levelSelect());
visible.add(strings.replay());
if (levelIndex + 1 < LevelCatalog.all().size()) visible.add(strings.nextLevel());
} else if (userPaused) {
visible.add(strings.paused());
visible.add(strings.resume());
visible.add(strings.levelSelect());
} else if (onboardingFlow.isVisible()) {
visible.add(onboardingTitle());
visible.add(onboardingBody());
visible.add(strings.tutorialSkip());
visible.add(onboardingFlow.step() == OnboardingFlow.Step.CORPSES_ARE_TOOLS
? strings.tutorialDone() : strings.tutorialNext());
}
}
return Collections.unmodifiableList(visible);
}
private void updateLanguage(GameLanguage updated) {
language = updated == null ? GameLanguage.EN : updated;
strings = GameStrings.forLanguage(language);
languageStore.save(language);
setContentDescription(strings.appContentDescription());
invalidate();
}
private void updateControlSettings(ControlSettings updated) {
controlSettings = updated;
controlSettingsStore.save(updated);