feat(l10n): 게임 텍스트 로컬라이제이션 확장

- game_text_l10n.dart: BuildContext 없이 사용할 수 있는 게임 텍스트 l10n 파일 생성
- progress_service.dart: 프롤로그/태스크 캡션 l10n 함수 사용으로 변경
- pq_logic.dart: 퀘스트/시네마틱/몬스터 수식어 l10n 함수 사용으로 변경

번역 적용 범위:
- 프롤로그 텍스트 (4개)
- 태스크 캡션 (컴파일, 이동, 디버깅, 판매 등)
- 퀘스트 캡션 (패치, 찾기, 전송, 다운로드, 안정화)
- 시네마틱 텍스트 (캐시 존, 전투, 배신 시나리오)
- 몬스터 수식어 (sick, young, big, special 등 모든 수식어)
- 시간 표시 (초, 분, 시간, 일)
- impressiveGuy, namedMonster 패턴
This commit is contained in:
JiWoong Sul
2025-12-11 18:49:02 +09:00
parent d4acd3503b
commit 0216eb1261
3 changed files with 389 additions and 127 deletions

View File

@@ -1,5 +1,6 @@
import 'dart:math' as math;
import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
import 'package:askiineverdie/src/core/engine/game_mutations.dart';
import 'package:askiineverdie/src/core/engine/reward_service.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
@@ -37,48 +38,47 @@ class ProgressService {
/// 새 게임 초기화 (원본 GoButtonClick, Main.pas:741-767)
/// Prologue 태스크들을 큐에 추가하고 첫 태스크 시작
GameState initializeNewGame(GameState state) {
// 초기 큐 설정 - 아스키나라(ASCII-Nara) 세계관 프롤로그
// 초기 큐 설정 - 아스키나라(ASCII-Nara) 세계관 프롤로그 (l10n 지원)
final prologueTexts = l10n.prologueTexts;
final initialQueue = <QueueEntry>[
const QueueEntry(
QueueEntry(
kind: QueueKind.task,
durationMillis: 10 * 1000,
caption: 'Receiving an ominous vision from the Code God',
caption: prologueTexts[0],
taskType: TaskType.load,
),
const QueueEntry(
QueueEntry(
kind: QueueKind.task,
durationMillis: 6 * 1000,
caption:
'The old Compiler Sage reveals a prophecy: '
'"The Glitch God has awakened"',
caption: prologueTexts[1],
taskType: TaskType.load,
),
const QueueEntry(
QueueEntry(
kind: QueueKind.task,
durationMillis: 6 * 1000,
caption:
'A sudden Buffer Overflow resets your village, '
'leaving you as the sole survivor',
caption: prologueTexts[2],
taskType: TaskType.load,
),
const QueueEntry(
QueueEntry(
kind: QueueKind.task,
durationMillis: 4 * 1000,
caption:
'With unexpected resolve, you embark on a perilous journey '
'to the Null Kingdom',
caption: prologueTexts[3],
taskType: TaskType.load,
),
const QueueEntry(
QueueEntry(
kind: QueueKind.plot,
durationMillis: 2 * 1000,
caption: 'Compiling',
caption: l10n.taskCompiling,
taskType: TaskType.plot,
),
];
// 첫 번째 태스크 시작 (원본 752줄)
final taskResult = pq_logic.startTask(state.progress, 'Compiling', 2 * 1000);
final taskResult = pq_logic.startTask(
state.progress,
l10n.taskCompiling,
2 * 1000,
);
// ExpBar 초기화 (원본 743-746줄)
final expBar = ProgressBarState(position: 0, max: pq_logic.levelUpTime(1));
@@ -89,10 +89,13 @@ class ProgressService {
final progress = taskResult.progress.copyWith(
exp: expBar,
plot: plotBar,
currentTask: const TaskInfo(caption: 'Compiling...', type: TaskType.load),
currentTask: TaskInfo(
caption: '${l10n.taskCompiling}...',
type: TaskType.load,
),
plotStageCount: 1, // Prologue
questCount: 0,
plotHistory: const [HistoryEntry(caption: 'Prologue', isComplete: false)],
plotHistory: [HistoryEntry(caption: l10n.taskPrologue, isComplete: false)],
questHistory: const [],
);
@@ -295,7 +298,7 @@ class ProgressService {
progress.encumbrance.max > 0) {
final taskResult = pq_logic.startTask(
progress,
'Heading to the Data Market to trade loot',
l10n.taskHeadingToMarket(),
4 * 1000,
);
progress = taskResult.progress.copyWith(
@@ -316,7 +319,7 @@ class ProgressService {
if (gold > equipPrice) {
final taskResult = pq_logic.startTask(
progress,
'Upgrading hardware at the Tech Shop',
l10n.taskUpgradingHardware(),
5 * 1000,
);
progress = taskResult.progress.copyWith(
@@ -331,7 +334,7 @@ class ProgressService {
// Gold가 부족하면 전장으로 이동 (원본 674-676줄)
final taskResult = pq_logic.startTask(
progress,
'Entering the Debug Zone',
l10n.taskEnteringDebugZone(),
4 * 1000,
);
progress = taskResult.progress.copyWith(
@@ -370,7 +373,7 @@ class ProgressService {
final taskResult = pq_logic.startTask(
progress,
'Debugging ${monsterResult.displayName}',
l10n.taskDebugging(monsterResult.displayName),
durationMillis,
);
@@ -711,9 +714,10 @@ class ProgressService {
if (hasItemsToSell) {
// 다음 아이템 판매 태스크 시작
final nextItem = items.first;
final itemDesc = l10n.indefiniteL10n(nextItem.name, nextItem.count);
final taskResult = pq_logic.startTask(
state.progress,
'Selling ${pq_logic.indefinite(nextItem.name, nextItem.count)}',
l10n.taskSelling(itemDesc),
1 * 1000,
);
final progress = taskResult.progress.copyWith(

View File

@@ -1,11 +1,12 @@
import 'dart:collection';
import 'dart:math' as math;
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
import 'package:askiineverdie/src/core/model/pq_config.dart';
import 'package:askiineverdie/src/core/util/roman.dart';
import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/pq_config.dart';
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
import 'package:askiineverdie/src/core/util/roman.dart';
// Mirrors core utility functions from the original Delphi sources (Main.pas / NewGuy.pas).
@@ -80,15 +81,16 @@ int levelUpTimeSeconds(int level) {
}
/// 초 단위 시간을 사람이 읽기 쉬운 형태로 변환 (원본 Main.pas:1265-1271)
/// l10n 지원
String roughTime(int seconds) {
if (seconds < 120) {
return '$seconds seconds';
return l10n.roughTimeSeconds(seconds);
} else if (seconds < 60 * 120) {
return '${seconds ~/ 60} minutes';
return l10n.roughTimeMinutes(seconds ~/ 60);
} else if (seconds < 60 * 60 * 48) {
return '${seconds ~/ 3600} hours';
return l10n.roughTimeHours(seconds ~/ 3600);
} else {
return '${seconds ~/ (3600 * 24)} days';
return l10n.roughTimeDays(seconds ~/ (3600 * 24));
}
}
@@ -512,7 +514,7 @@ MonsterTaskResult monsterTask(
if (rng.nextInt(2) == 0) {
// 'passing Race Class' 형태
final klass = pick(config.klasses, rng).split('|').first;
monster = 'passing $race $klass';
monster = l10n.modifierPassing('$race $klass');
} else {
// 'Title Name the Race' 형태 (원본은 PickLow(Titles) 사용)
final title = pickLow(config.titles, rng);
@@ -562,7 +564,7 @@ MonsterTaskResult monsterTask(
}
if (levelDiff <= -10) {
name = 'imaginary $name';
name = l10n.modifierImaginary(name);
} else if (levelDiff < -5) {
final i = 5 - rng.nextInt(10 + levelDiff + 1);
name = _sick(i, _young((monsterLevel - targetLevel) - i, name));
@@ -573,7 +575,7 @@ MonsterTaskResult monsterTask(
name = _young(levelDiff, name);
}
} else if (levelDiff >= 10) {
name = 'messianic $name';
name = l10n.modifierMessianic(name);
} else if (levelDiff > 5) {
final i = 5 - rng.nextInt(10 - levelDiff + 1);
name = _big(i, _special((levelDiff) - i, name));
@@ -669,7 +671,7 @@ QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
}
final name = best.split('|').first;
return QuestResult(
caption: 'Patch ${definite(name, 2)}',
caption: l10n.questPatch(l10n.definiteL10n(name, 2)),
reward: reward,
monsterName: best,
monsterLevel: bestLevel,
@@ -677,14 +679,20 @@ QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
);
case 1:
final item = interestingItem(config, rng);
return QuestResult(caption: 'Locate ${definite(item, 1)}', reward: reward);
return QuestResult(
caption: l10n.questLocate(l10n.definiteL10n(item, 1)),
reward: reward,
);
case 2:
final item = boringItem(config, rng);
return QuestResult(caption: 'Transfer this $item', reward: reward);
return QuestResult(
caption: l10n.questTransfer(item),
reward: reward,
);
case 3:
final item = boringItem(config, rng);
return QuestResult(
caption: 'Download ${indefinite(item, 1)}',
caption: l10n.questDownload(l10n.indefiniteL10n(item, 1)),
reward: reward,
);
default:
@@ -703,7 +711,7 @@ QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
final name = best.split('|').first;
// Stabilize는 fQuest.Caption := '' 로 비움 → monsterIndex 미저장
return QuestResult(
caption: 'Stabilize ${definite(name, 2)}',
caption: l10n.questStabilize(l10n.definiteL10n(name, 2)),
reward: reward,
);
}
@@ -723,7 +731,7 @@ class ActResult {
ActResult completeAct(int existingActCount) {
final nextActIndex = existingActCount;
final title = 'Act ${intToRoman(nextActIndex)}';
final title = l10n.actTitle(intToRoman(nextActIndex));
final plotBarMax = 60 * 60 * (1 + 5 * existingActCount);
final rewards = <RewardKind>[];
@@ -815,19 +823,19 @@ String _sick(int m, String s) {
switch (m) {
case -5:
case 5:
return 'dead $s';
return l10n.modifierDead(s);
case -4:
case 4:
return 'comatose $s';
return l10n.modifierComatose(s);
case -3:
case 3:
return 'crippled $s';
return l10n.modifierCrippled(s);
case -2:
case 2:
return 'sick $s';
return l10n.modifierSick(s);
case -1:
case 1:
return 'undernourished $s';
return l10n.modifierUndernourished(s);
default:
return '$m$s';
}
@@ -837,19 +845,19 @@ String _young(int m, String s) {
switch (-m) {
case -5:
case 5:
return 'foetal $s';
return l10n.modifierFoetal(s);
case -4:
case 4:
return 'baby $s';
return l10n.modifierBaby(s);
case -3:
case 3:
return 'preadolescent $s';
return l10n.modifierPreadolescent(s);
case -2:
case 2:
return 'teenage $s';
return l10n.modifierTeenage(s);
case -1:
case 1:
return 'underage $s';
return l10n.modifierUnderage(s);
default:
return '$m$s';
}
@@ -859,19 +867,19 @@ String _big(int m, String s) {
switch (m) {
case 1:
case -1:
return 'greater $s';
return l10n.modifierGreater(s);
case 2:
case -2:
return 'massive $s';
return l10n.modifierMassive(s);
case 3:
case -3:
return 'enormous $s';
return l10n.modifierEnormous(s);
case 4:
case -4:
return 'giant $s';
return l10n.modifierGiant(s);
case 5:
case -5:
return 'titanic $s';
return l10n.modifierTitanic(s);
default:
return s;
}
@@ -881,19 +889,19 @@ String _special(int m, String s) {
switch (-m) {
case 1:
case -1:
return s.contains(' ') ? 'veteran $s' : 'Battle-$s';
return s.contains(' ') ? l10n.modifierVeteran(s) : l10n.modifierBattle(s);
case 2:
case -2:
return 'cursed $s';
return l10n.modifierCursed(s);
case 3:
case -3:
return s.contains(' ') ? 'warrior $s' : 'Were-$s';
return s.contains(' ') ? l10n.modifierWarrior(s) : l10n.modifierWere(s);
case 4:
case -4:
return 'undead $s';
return l10n.modifierUndead(s);
case 5:
case -5:
return 'demon $s';
return l10n.modifierDemon(s);
default:
return s;
}
@@ -916,25 +924,26 @@ String _pick(String pipeSeparated, DeterministicRandom rng) {
// =============================================================================
/// NPC 이름 생성 (ImpressiveGuy, Main.pas:514-521)
/// 인상적인 타이틀 + 종족 또는 이름 조합
/// 인상적인 타이틀 + 종족 또는 이름 조합 (l10n 지원)
String impressiveGuy(PqConfig config, DeterministicRandom rng) {
var result = pick(config.impressiveTitles, rng);
final title = pick(config.impressiveTitles, rng);
switch (rng.nextInt(2)) {
case 0:
// "the King of the Elves" 형태
// "the King of the Elves" / "엘프들의 왕" 형태
final race = pick(config.races, rng).split('|').first;
result = 'the $result of the ${pluralize(race)}';
break;
return l10n.impressiveGuyPattern1(title, race);
case 1:
// "King Vrognak of Zoxzik" 형태
result = '$result ${generateName(rng)} of ${generateName(rng)}';
break;
// "King Vrognak of Zoxzik" / "Zoxzik의 왕 Vrognak" 형태
final name1 = generateName(rng);
final name2 = generateName(rng);
return l10n.impressiveGuyPattern2(title, name1, name2);
default:
return title;
}
return result;
}
/// 이름 있는 몬스터 생성 (NamedMonster, Main.pas:498-512)
/// 레벨에 맞는 몬스터 선택 후 GenerateName으로 이름 붙이기
/// 레벨에 맞는 몬스터 선택 후 GenerateName으로 이름 붙이기 (l10n 지원)
String namedMonster(PqConfig config, DeterministicRandom rng, int level) {
String best = '';
int bestLevel = 0;
@@ -952,11 +961,12 @@ String namedMonster(PqConfig config, DeterministicRandom rng, int level) {
}
}
return '${generateName(rng)} the $best';
// "GeneratedName the MonsterType" / "몬스터타입 GeneratedName" 형태
return l10n.namedMonsterFormat(generateName(rng), best);
}
/// 플롯 간 시네마틱 이벤트 생성 (InterplotCinematic, Main.pas:456-495)
/// 3가지 시나리오 중 하나를 랜덤 선택
/// 3가지 시나리오 중 하나를 랜덤 선택 (l10n 지원)
List<QueueEntry> interplotCinematic(
PqConfig config,
DeterministicRandom rng,
@@ -975,25 +985,17 @@ List<QueueEntry> interplotCinematic(
switch (rng.nextInt(3)) {
case 0:
// 시나리오 1: 안전한 캐시 영역 도착
q(
QueueKind.task,
1,
'Exhausted, you reach a safe Cache Zone in the corrupted network',
);
q(QueueKind.task, 2, 'You reconnect with old allies and fork new ones');
q(QueueKind.task, 2, 'You attend a council of the Debugger Knights');
q(QueueKind.task, 1, 'Many bugs await. You are chosen to patch them!');
q(QueueKind.task, 1, l10n.cinematicCacheZone1());
q(QueueKind.task, 2, l10n.cinematicCacheZone2());
q(QueueKind.task, 2, l10n.cinematicCacheZone3());
q(QueueKind.task, 1, l10n.cinematicCacheZone4());
break;
case 1:
// 시나리오 2: 강력한 버그와의 전투
q(
QueueKind.task,
1,
'Your target is in sight, but a critical bug blocks your path!',
);
q(QueueKind.task, 1, l10n.cinematicCombat1());
final nemesis = namedMonster(config, rng, level + 3);
q(QueueKind.task, 4, 'A desperate debugging session begins with $nemesis');
q(QueueKind.task, 4, l10n.cinematicCombat2(nemesis));
var s = rng.nextInt(3);
final combatRounds = rng.nextInt(1 + plotCount);
@@ -1001,63 +1003,35 @@ List<QueueEntry> interplotCinematic(
s += 1 + rng.nextInt(2);
switch (s % 3) {
case 0:
q(QueueKind.task, 2, 'Locked in intense debugging with $nemesis');
q(QueueKind.task, 2, l10n.cinematicCombatLocked(nemesis));
break;
case 1:
q(QueueKind.task, 2, '$nemesis corrupts your stack trace');
q(QueueKind.task, 2, l10n.cinematicCombatCorrupts(nemesis));
break;
case 2:
q(
QueueKind.task,
2,
'Your patch seems to be working against $nemesis',
);
q(QueueKind.task, 2, l10n.cinematicCombatWorking(nemesis));
break;
}
}
q(
QueueKind.task,
3,
'Victory! $nemesis is patched! System reboots for recovery',
);
q(
QueueKind.task,
2,
'You wake up in a Safe Mode, but the kernel awaits',
);
q(QueueKind.task, 3, l10n.cinematicCombatVictory(nemesis));
q(QueueKind.task, 2, l10n.cinematicCombatWakeUp());
break;
case 2:
// 시나리오 3: 내부자 위협 발견
final guy = impressiveGuy(config, rng);
q(
QueueKind.task,
2,
'What relief! You reach the secure server of $guy',
);
q(
QueueKind.task,
3,
'There is celebration, and a suspicious private handshake with $guy',
);
q(
QueueKind.task,
2,
'You forget your ${boringItem(config, rng)} and go back to retrieve it',
);
q(QueueKind.task, 2, 'What is this!? You intercept a corrupted packet!');
q(QueueKind.task, 2, 'Could $guy be a backdoor for the Glitch God?');
q(
QueueKind.task,
3,
'Who can be trusted with this intel!? -- The Binary Temple, of course',
);
q(QueueKind.task, 2, l10n.cinematicBetrayal1(guy));
q(QueueKind.task, 3, l10n.cinematicBetrayal2(guy));
q(QueueKind.task, 2, l10n.cinematicBetrayal3(boringItem(config, rng)));
q(QueueKind.task, 2, l10n.cinematicBetrayal4());
q(QueueKind.task, 2, l10n.cinematicBetrayal5(guy));
q(QueueKind.task, 3, l10n.cinematicBetrayal6());
break;
}
// 마지막에 plot 추가
q(QueueKind.plot, 2, 'Compiling');
q(QueueKind.plot, 2, l10n.taskCompiling);
return entries;
}