feat(l10n): 장비/아이템 동적 이름 한국어 번역 지원
- pq_logic.dart: 구조화된 결과 타입 (EquipResult, ItemResult) 추가 - pq_logic.dart: 구조화된 생성 함수 (winEquipStructured, winItemStructured 등) 추가 - GameDataL10n: 구조화된 결과 렌더링 함수 추가 (renderEquipResult, renderItemResult) - GameDataL10n: 문자열 파싱 기반 번역 함수 추가 (translateEquipString, translateItemString) - game_play_screen.dart: 장비/아이템 목록에 번역 함수 적용
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'package:askiineverdie/data/game_translations_ko.dart';
|
||||
import 'package:askiineverdie/src/core/util/pq_logic.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// 게임 데이터 번역을 위한 헬퍼 클래스
|
||||
@@ -169,4 +170,220 @@ class GameDataL10n {
|
||||
}
|
||||
return englishName;
|
||||
}
|
||||
|
||||
/// 구조화된 장비 결과를 로컬라이즈된 문자열로 렌더링
|
||||
/// 예: EquipResult(baseName: "Keyboard", modifiers: ["Optimized"], plusValue: 2)
|
||||
/// → 영어: "+2 Optimized Keyboard"
|
||||
/// → 한국어: "+2 최적화된 키보드"
|
||||
static String renderEquipResult(
|
||||
BuildContext context,
|
||||
EquipResult result,
|
||||
int slotIndex,
|
||||
) {
|
||||
if (_isKorean(context)) {
|
||||
return _renderEquipResultKo(result, slotIndex);
|
||||
}
|
||||
return result.displayName;
|
||||
}
|
||||
|
||||
/// 한국어 장비 렌더링 (내부 함수)
|
||||
static String _renderEquipResultKo(EquipResult result, int slotIndex) {
|
||||
// 기본 장비 이름 번역
|
||||
String baseName;
|
||||
if (slotIndex == 0) {
|
||||
baseName = weaponTranslationsKo[result.baseName] ?? result.baseName;
|
||||
} else if (slotIndex == 1) {
|
||||
baseName = shieldTranslationsKo[result.baseName] ?? result.baseName;
|
||||
} else {
|
||||
baseName = armorTranslationsKo[result.baseName] ?? result.baseName;
|
||||
}
|
||||
|
||||
// 수식어 번역 (공격용 vs 방어용)
|
||||
final isWeapon = slotIndex == 0;
|
||||
final translatedModifiers = result.modifiers.map((mod) {
|
||||
if (isWeapon) {
|
||||
// 공격 속성: offenseAttrib 또는 offenseBad
|
||||
return offenseAttribTranslationsKo[mod] ??
|
||||
offenseBadTranslationsKo[mod] ??
|
||||
mod;
|
||||
} else {
|
||||
// 방어 속성: defenseAttrib 또는 defenseBad
|
||||
return defenseAttribTranslationsKo[mod] ??
|
||||
defenseBadTranslationsKo[mod] ??
|
||||
mod;
|
||||
}
|
||||
}).toList();
|
||||
|
||||
// 조합: 수식어들 + 기본 이름
|
||||
var name = baseName;
|
||||
for (final mod in translatedModifiers) {
|
||||
name = '$mod $name';
|
||||
}
|
||||
|
||||
// +/- 수치 추가
|
||||
if (result.plusValue != 0) {
|
||||
final sign = result.plusValue > 0 ? '+' : '';
|
||||
name = '$sign${result.plusValue} $name';
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/// 구조화된 아이템 결과를 로컬라이즈된 문자열로 렌더링
|
||||
/// 예: ItemResult(attrib: "Golden", special: "Iterator", itemOf: "Compilation")
|
||||
/// → 영어: "Golden Iterator of Compilation"
|
||||
/// → 한국어: "컴파일의 황금 이터레이터"
|
||||
static String renderItemResult(BuildContext context, ItemResult result) {
|
||||
if (_isKorean(context)) {
|
||||
return _renderItemResultKo(result);
|
||||
}
|
||||
return result.displayName;
|
||||
}
|
||||
|
||||
/// 한국어 아이템 렌더링 (내부 함수)
|
||||
static String _renderItemResultKo(ItemResult result) {
|
||||
// 단순 아이템 (boringItem)
|
||||
if (result.boringItem != null) {
|
||||
// boringItem은 별도 번역 맵이 필요할 수 있음
|
||||
// 현재는 그대로 반환 (대부분 영어 그대로 사용)
|
||||
return result.boringItem!;
|
||||
}
|
||||
|
||||
// 복합 아이템: attrib + special + itemOf
|
||||
final attrib = result.attrib != null
|
||||
? (itemAttribTranslationsKo[result.attrib] ?? result.attrib!)
|
||||
: null;
|
||||
final special = result.special != null
|
||||
? (specialTranslationsKo[result.special] ?? result.special!)
|
||||
: null;
|
||||
final itemOf = result.itemOf != null
|
||||
? (itemOfsTranslationsKo[result.itemOf] ?? result.itemOf!)
|
||||
: null;
|
||||
|
||||
// 한국어 어순: "X의 Y" 패턴
|
||||
// "Golden Iterator of Compilation" → "컴파일의 황금 이터레이터"
|
||||
if (attrib != null && special != null && itemOf != null) {
|
||||
return '$itemOf의 $attrib $special';
|
||||
}
|
||||
|
||||
// attrib + special만 있는 경우
|
||||
if (attrib != null && special != null) {
|
||||
return '$attrib $special';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/// 장비 이름 문자열 파싱 후 번역 (기존 저장 데이터 호환)
|
||||
/// 예: "+2 Optimized GPU-Powered Keyboard" → "+2 최적화된 GPU 파워 키보드"
|
||||
static String translateEquipString(
|
||||
BuildContext context,
|
||||
String equipString,
|
||||
int slotIndex,
|
||||
) {
|
||||
if (!_isKorean(context) || equipString.isEmpty) return equipString;
|
||||
|
||||
// 1. +/- 값 추출
|
||||
final plusMatch = RegExp(r'^([+-]?\d+)\s+').firstMatch(equipString);
|
||||
String remaining = equipString;
|
||||
String plusPart = '';
|
||||
if (plusMatch != null) {
|
||||
plusPart = plusMatch.group(1)!;
|
||||
remaining = equipString.substring(plusMatch.end);
|
||||
}
|
||||
|
||||
// 2. 기본 장비 이름 찾기 (가장 긴 매칭 우선)
|
||||
final Map<String, String> baseMap;
|
||||
if (slotIndex == 0) {
|
||||
baseMap = weaponTranslationsKo;
|
||||
} else if (slotIndex == 1) {
|
||||
baseMap = shieldTranslationsKo;
|
||||
} else {
|
||||
baseMap = armorTranslationsKo;
|
||||
}
|
||||
|
||||
String baseTranslated = remaining;
|
||||
String modifierPart = '';
|
||||
|
||||
// 기본 장비 이름을 뒤에서부터 찾기
|
||||
for (final entry in baseMap.entries) {
|
||||
if (remaining.endsWith(entry.key)) {
|
||||
baseTranslated = entry.value;
|
||||
modifierPart = remaining.substring(
|
||||
0,
|
||||
remaining.length - entry.key.length,
|
||||
).trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 수식어 번역
|
||||
final isWeapon = slotIndex == 0;
|
||||
final modWords = modifierPart.split(' ').where((s) => s.isNotEmpty).toList();
|
||||
final translatedMods = modWords.map((mod) {
|
||||
if (isWeapon) {
|
||||
return offenseAttribTranslationsKo[mod] ??
|
||||
offenseBadTranslationsKo[mod] ??
|
||||
mod;
|
||||
} else {
|
||||
return defenseAttribTranslationsKo[mod] ??
|
||||
defenseBadTranslationsKo[mod] ??
|
||||
mod;
|
||||
}
|
||||
}).toList();
|
||||
|
||||
// 4. 조합
|
||||
var result = baseTranslated;
|
||||
for (final mod in translatedMods.reversed) {
|
||||
result = '$mod $result';
|
||||
}
|
||||
if (plusPart.isNotEmpty) {
|
||||
result = '$plusPart $result';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 아이템 이름 문자열 파싱 후 번역 (기존 저장 데이터 호환)
|
||||
/// 예: "Golden Iterator of Compilation" → "컴파일의 황금 이터레이터"
|
||||
static String translateItemString(BuildContext context, String itemString) {
|
||||
if (!_isKorean(context) || itemString.isEmpty) return itemString;
|
||||
|
||||
// "X Y of Z" 패턴 파싱
|
||||
final ofMatch = RegExp(r'^(.+)\s+of\s+(.+)$').firstMatch(itemString);
|
||||
|
||||
if (ofMatch != null) {
|
||||
final beforeOf = ofMatch.group(1)!; // "Golden Iterator"
|
||||
final afterOf = ofMatch.group(2)!; // "Compilation"
|
||||
|
||||
// "X Y" 분리 (마지막 단어가 special)
|
||||
final words = beforeOf.split(' ');
|
||||
if (words.length >= 2) {
|
||||
final attrib = words.sublist(0, words.length - 1).join(' ');
|
||||
final special = words.last;
|
||||
|
||||
final attribKo = itemAttribTranslationsKo[attrib] ?? attrib;
|
||||
final specialKo = specialTranslationsKo[special] ?? special;
|
||||
final itemOfKo = itemOfsTranslationsKo[afterOf] ?? afterOf;
|
||||
|
||||
// 한국어 어순: "ItemOf의 Attrib Special"
|
||||
return '$itemOfKo의 $attribKo $specialKo';
|
||||
}
|
||||
}
|
||||
|
||||
// "X Y" 패턴 (of 없음)
|
||||
final words = itemString.split(' ');
|
||||
if (words.length == 2) {
|
||||
final attrib = words[0];
|
||||
final special = words[1];
|
||||
|
||||
final attribKo = itemAttribTranslationsKo[attrib] ?? attrib;
|
||||
final specialKo = specialTranslationsKo[special] ?? special;
|
||||
|
||||
return '$attribKo $specialKo';
|
||||
}
|
||||
|
||||
// 단일 단어 (boringItem 등)
|
||||
return itemString;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,70 @@ import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||
|
||||
// Mirrors core utility functions from the original Delphi sources (Main.pas / NewGuy.pas).
|
||||
|
||||
/// 장비 생성 결과 (구조화된 데이터로 l10n 지원)
|
||||
class EquipResult {
|
||||
const EquipResult({
|
||||
required this.baseName,
|
||||
this.modifiers = const [],
|
||||
this.plusValue = 0,
|
||||
});
|
||||
|
||||
/// 기본 장비 이름 (예: "VPN Cloak")
|
||||
final String baseName;
|
||||
|
||||
/// 수식어 목록 (예: ["Holey", "Deprecated"])
|
||||
final List<String> modifiers;
|
||||
|
||||
/// +/- 수치 (예: -1, +2)
|
||||
final int plusValue;
|
||||
|
||||
/// 영문 전체 이름 생성 (기존 방식)
|
||||
String get displayName {
|
||||
var name = baseName;
|
||||
for (final mod in modifiers) {
|
||||
name = '$mod $name';
|
||||
}
|
||||
if (plusValue != 0) {
|
||||
name = '${plusValue > 0 ? '+' : ''}$plusValue $name';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
/// 아이템 생성 결과 (구조화된 데이터로 l10n 지원)
|
||||
class ItemResult {
|
||||
const ItemResult({
|
||||
this.attrib,
|
||||
this.special,
|
||||
this.itemOf,
|
||||
this.boringItem,
|
||||
});
|
||||
|
||||
/// 아이템 속성 (예: "Golden")
|
||||
final String? attrib;
|
||||
|
||||
/// 특수 아이템 (예: "Iterator")
|
||||
final String? special;
|
||||
|
||||
/// "~의" 접미사 (예: "Monitoring")
|
||||
final String? itemOf;
|
||||
|
||||
/// 단순 아이템 (보링 아이템용)
|
||||
final String? boringItem;
|
||||
|
||||
/// 영문 전체 이름 생성 (기존 방식)
|
||||
String get displayName {
|
||||
if (boringItem != null) return boringItem!;
|
||||
if (attrib != null && special != null && itemOf != null) {
|
||||
return '$attrib $special of $itemOf';
|
||||
}
|
||||
if (attrib != null && special != null) {
|
||||
return '$attrib $special';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
int levelUpTimeSeconds(int level) {
|
||||
// ~20 minutes for level 1, then exponential growth (same as LevelUpTime in Main.pas).
|
||||
final seconds = (20.0 + math.pow(1.15, level)) * 60.0;
|
||||
@@ -101,6 +165,41 @@ String specialItem(PqConfig config, DeterministicRandom rng) {
|
||||
return '${interestingItem(config, rng)} of ${pick(config.itemOfs, rng)}';
|
||||
}
|
||||
|
||||
/// 구조화된 아이템 결과 반환 (l10n 지원)
|
||||
ItemResult boringItemStructured(PqConfig config, DeterministicRandom rng) {
|
||||
return ItemResult(boringItem: pick(config.boringItems, rng));
|
||||
}
|
||||
|
||||
/// 구조화된 아이템 결과 반환 (l10n 지원)
|
||||
ItemResult interestingItemStructured(PqConfig config, DeterministicRandom rng) {
|
||||
return ItemResult(
|
||||
attrib: pick(config.itemAttrib, rng),
|
||||
special: pick(config.specials, rng),
|
||||
);
|
||||
}
|
||||
|
||||
/// 구조화된 아이템 결과 반환 (l10n 지원)
|
||||
ItemResult specialItemStructured(PqConfig config, DeterministicRandom rng) {
|
||||
return ItemResult(
|
||||
attrib: pick(config.itemAttrib, rng),
|
||||
special: pick(config.specials, rng),
|
||||
itemOf: pick(config.itemOfs, rng),
|
||||
);
|
||||
}
|
||||
|
||||
/// 구조화된 아이템 결과 반환 (l10n 지원)
|
||||
ItemResult winItemStructured(
|
||||
PqConfig config,
|
||||
DeterministicRandom rng,
|
||||
int inventoryCount,
|
||||
) {
|
||||
final threshold = math.max(250, rng.nextInt(999));
|
||||
if (inventoryCount > threshold) {
|
||||
return const ItemResult(); // 빈 결과
|
||||
}
|
||||
return specialItemStructured(config, rng);
|
||||
}
|
||||
|
||||
String pickWeapon(PqConfig config, DeterministicRandom rng, int level) {
|
||||
return _lPick(config.weapons, rng, level);
|
||||
}
|
||||
@@ -256,6 +355,78 @@ String winEquipBySlot(
|
||||
return winEquip(config, rng, level, slot.index);
|
||||
}
|
||||
|
||||
/// 구조화된 장비 생성 결과 반환 (l10n 지원)
|
||||
EquipResult winEquipStructured(
|
||||
PqConfig config,
|
||||
DeterministicRandom rng,
|
||||
int level,
|
||||
int slotIndex,
|
||||
) {
|
||||
final bool isWeapon = slotIndex == 0;
|
||||
final List<String> items;
|
||||
if (slotIndex == 0) {
|
||||
items = config.weapons;
|
||||
} else if (slotIndex == 1) {
|
||||
items = config.shields;
|
||||
} else {
|
||||
items = config.armors;
|
||||
}
|
||||
final better = isWeapon ? config.offenseAttrib : config.defenseAttrib;
|
||||
final worse = isWeapon ? config.offenseBad : config.defenseBad;
|
||||
|
||||
final base = _lPick(items, rng, level);
|
||||
final parts = base.split('|');
|
||||
final baseName = parts[0];
|
||||
final qual = parts.length > 1
|
||||
? int.tryParse(parts[1].replaceAll('+', '')) ?? 0
|
||||
: 0;
|
||||
|
||||
final plus = level - qual;
|
||||
final modifierList = plus >= 0 ? better : worse;
|
||||
return _addModifierStructured(rng, baseName, modifierList, plus);
|
||||
}
|
||||
|
||||
/// 구조화된 장비 결과 반환 (내부 함수)
|
||||
EquipResult _addModifierStructured(
|
||||
DeterministicRandom rng,
|
||||
String baseName,
|
||||
List<String> modifiers,
|
||||
int plus,
|
||||
) {
|
||||
final collectedModifiers = <String>[];
|
||||
var remaining = plus;
|
||||
var count = 0;
|
||||
|
||||
while (count < 2 && remaining != 0) {
|
||||
final modifier = pick(modifiers, rng);
|
||||
final parts = modifier.split('|');
|
||||
if (parts.isEmpty) break;
|
||||
final label = parts[0];
|
||||
final qual = parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0;
|
||||
if (collectedModifiers.contains(label)) break; // avoid repeats
|
||||
if (remaining.abs() < qual.abs()) break;
|
||||
collectedModifiers.add(label);
|
||||
remaining -= qual;
|
||||
count++;
|
||||
}
|
||||
|
||||
return EquipResult(
|
||||
baseName: baseName,
|
||||
modifiers: collectedModifiers,
|
||||
plusValue: remaining,
|
||||
);
|
||||
}
|
||||
|
||||
/// EquipmentSlot enum을 사용하는 구조화된 버전
|
||||
EquipResult winEquipBySlotStructured(
|
||||
PqConfig config,
|
||||
DeterministicRandom rng,
|
||||
int level,
|
||||
EquipmentSlot slot,
|
||||
) {
|
||||
return winEquipStructured(config, rng, level, slot.index);
|
||||
}
|
||||
|
||||
int winStatIndex(DeterministicRandom rng, List<int> statValues) {
|
||||
// 원본 Main.pas:870-883
|
||||
// 50%: 모든 8개 스탯 중 랜덤
|
||||
|
||||
@@ -510,19 +510,20 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
|
||||
Widget _buildEquipmentList(GameState state) {
|
||||
// 원본 Main.dfm Equips ListView - 11개 슬롯
|
||||
// (슬롯 레이블, 장비 이름, 슬롯 인덱스) 튜플
|
||||
final l10n = L10n.of(context);
|
||||
final equipment = [
|
||||
(l10n.equipWeapon, state.equipment.weapon),
|
||||
(l10n.equipShield, state.equipment.shield),
|
||||
(l10n.equipHelm, state.equipment.helm),
|
||||
(l10n.equipHauberk, state.equipment.hauberk),
|
||||
(l10n.equipBrassairts, state.equipment.brassairts),
|
||||
(l10n.equipVambraces, state.equipment.vambraces),
|
||||
(l10n.equipGauntlets, state.equipment.gauntlets),
|
||||
(l10n.equipGambeson, state.equipment.gambeson),
|
||||
(l10n.equipCuisses, state.equipment.cuisses),
|
||||
(l10n.equipGreaves, state.equipment.greaves),
|
||||
(l10n.equipSollerets, state.equipment.sollerets),
|
||||
(l10n.equipWeapon, state.equipment.weapon, 0),
|
||||
(l10n.equipShield, state.equipment.shield, 1),
|
||||
(l10n.equipHelm, state.equipment.helm, 2),
|
||||
(l10n.equipHauberk, state.equipment.hauberk, 3),
|
||||
(l10n.equipBrassairts, state.equipment.brassairts, 4),
|
||||
(l10n.equipVambraces, state.equipment.vambraces, 5),
|
||||
(l10n.equipGauntlets, state.equipment.gauntlets, 6),
|
||||
(l10n.equipGambeson, state.equipment.gambeson, 7),
|
||||
(l10n.equipCuisses, state.equipment.cuisses, 8),
|
||||
(l10n.equipGreaves, state.equipment.greaves, 9),
|
||||
(l10n.equipSollerets, state.equipment.sollerets, 10),
|
||||
];
|
||||
|
||||
return ListView.builder(
|
||||
@@ -530,6 +531,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final equip = equipment[index];
|
||||
// 장비 이름 번역 (슬롯 인덱스 사용)
|
||||
final translatedName = equip.$2.isNotEmpty
|
||||
? GameDataL10n.translateEquipString(context, equip.$2, equip.$3)
|
||||
: '-';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
@@ -540,7 +545,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
equip.$2.isNotEmpty ? equip.$2 : '-',
|
||||
translatedName,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -587,11 +592,16 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
);
|
||||
}
|
||||
final item = state.inventory.items[index - 1];
|
||||
// 아이템 이름 번역
|
||||
final translatedName = GameDataL10n.translateItemString(
|
||||
context,
|
||||
item.name,
|
||||
);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.name,
|
||||
translatedName,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user