Compare commits

..

9 Commits

Author SHA1 Message Date
JiWoong Sul
d9a2fe358c refactor(ui): 화면 및 위젯 정리
- GamePlayScreen build() 메서드 분할 (300→15 LOC)
- 애니메이션/프로그레스 패널 개선
- 설정 화면 정리
2026-01-21 17:34:47 +09:00
JiWoong Sul
faf87eccb0 feat(l10n): 다국어 문자열 추가
- 옵션 메뉴, 통계, 도움말 등 UI 문자열 추가
- en, ko, ja, zh 지원
2026-01-21 17:34:39 +09:00
JiWoong Sul
7f44e95163 refactor(engine): tick() 메서드 분할 (350→80 LOC)
- 8개 헬퍼 메서드로 책임 분리
- _generateNextTask() 35 LOC로 감소
2026-01-21 17:34:31 +09:00
JiWoong Sul
742b0d1773 docs: CHANGELOG 및 아키텍처 문서 추가
- CHANGELOG.md: 리팩토링 내역 기록
- docs/ARCHITECTURE.md: ASCII 다이어그램으로 구조 시각화
2026-01-21 17:34:11 +09:00
JiWoong Sul
97b40ccb1f fix(lint): analyzer 경고 정리
- JsonKey ignore 주석 추가 (equipment_item, monetization_state)
- 미사용 import 제거 (panel_header)
- displayColor → displayColorCode (monster_grade, Flutter 의존성 제거)
2026-01-21 17:34:06 +09:00
JiWoong Sul
75bc39528f refactor(ui): new_character_screen.dart 분할 (1016→544 LOC)
- NameInputSection: 이름 입력 섹션
- StatsSection: 능력치 섹션 (스탯 타일, 롤/언두 버튼)
- RaceSelectionSection: 종족 선택 섹션
- ClassSelectionSection: 직업 선택 섹션
2026-01-21 17:33:59 +09:00
JiWoong Sul
c5eaecfa6a refactor(ui): mobile_carousel_layout.dart 분할 (1220→689 LOC)
- RetroSelectDialog, RetroOptionItem: 선택 다이얼로그
- RetroSoundDialog: 사운드 설정 다이얼로그
- RetroConfirmDialog: 확인 다이얼로그
- RetroMenuSection, RetroMenuItem, RetroSpeedChip: 메뉴 위젯
2026-01-21 17:33:52 +09:00
JiWoong Sul
c577f9deed refactor(controller): GameSessionController 분할 (920→526 LOC)
- GameStatisticsManager: 세션/누적 통계 추적
- SpeedBoostManager: 광고 배속 부스트 기능
- ReturnRewardsManager: 복귀 보상 기능
- ResurrectionManager: 사망/부활 처리
- HallOfFameManager: 명예의 전당 관리
2026-01-21 17:33:37 +09:00
JiWoong Sul
e516076ce8 refactor(model): game_state.dart 분할 (SRP 개선)
- Stats, Traits, Inventory, Equipment 등 11개 파일로 분리
- 단일 책임 원칙 적용으로 유지보수성 향상
2026-01-21 17:33:30 +09:00
51 changed files with 5680 additions and 3261 deletions

46
CHANGELOG.md Normal file
View File

@@ -0,0 +1,46 @@
# Changelog
프로젝트의 주요 변경 사항을 기록합니다.
## [Unreleased]
### Refactored (리팩토링)
#### GameSessionController 분할 (SRP 개선)
- 920 LOC → 526 LOC (43% 감소)
- 5개 매니저로 책임 분리:
- `GameStatisticsManager` - 세션/누적 통계 추적
- `SpeedBoostManager` - 광고 배속 부스트 기능
- `ReturnRewardsManager` - 복귀 보상 기능
- `ResurrectionManager` - 사망/부활 처리
- `HallOfFameManager` - 명예의 전당 관리
#### ProgressService 메서드 분할
- `tick()`: 350 LOC → 80 LOC (8개 헬퍼 메서드)
- `_generateNextTask()`: 200 LOC → 35 LOC (6개 헬퍼 메서드)
#### GamePlayScreen 메서드 분할
- `build()`: 300 LOC → 15 LOC (5개 헬퍼 메서드)
#### Clean Architecture 개선
- `MonsterGrade.displayColor` (Color) → `displayColorCode` (int)
- Domain 레이어에서 Flutter 의존성 제거
### Fixed (버그 수정)
#### Analyzer 경고 정리
- 미사용 import 제거 (`panel_header.dart`)
- 미사용 필드 제거 (`new_character_screen.dart`)
- JsonKey 경고 억제 (`equipment_item.dart`, `monetization_state.dart`)
---
## 버전 표기 규칙
- `Added`: 새로운 기능 추가
- `Changed`: 기존 기능 변경
- `Deprecated`: 곧 제거될 기능
- `Removed`: 제거된 기능
- `Fixed`: 버그 수정
- `Security`: 보안 관련 수정
- `Refactored`: 코드 구조 개선 (기능 변화 없음)

208
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,208 @@
# 아키텍처 문서
## 디렉토리 구조
```
lib/
├── main.dart # 앱 진입점
├── data/ # 정적 데이터 (Config.dfm 추출)
│ ├── pq_config_data.dart
│ ├── race_data.dart
│ ├── class_data.dart
│ └── skill_data.dart
├── l10n/ # i18n 생성 파일
└── src/
├── app.dart # MaterialApp 설정
├── core/ # 도메인 레이어
│ ├── animation/ # ASCII 애니메이션
│ ├── audio/ # 오디오 서비스
│ ├── engine/ # 게임 로직
│ ├── model/ # 데이터 모델
│ ├── storage/ # 저장/로드
│ └── util/ # 유틸리티
├── features/ # 프레젠테이션 레이어
│ ├── arena/ # 아레나 화면
│ ├── front/ # 프론트 화면
│ ├── game/ # 게임 화면
│ ├── hall_of_fame/ # 명예의 전당
│ ├── new_character/ # 캐릭터 생성
│ └── settings/ # 설정
└── shared/ # 공통 위젯/스타일
```
## 레이어 구조 (Clean Architecture)
```
┌─────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Screens │ │ Widgets │ │ Controllers │ │
│ │ (features/)│ │ (widgets/) │ │ (game_session_ │ │
│ │ │ │ │ │ controller.dart) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
└─────────┼────────────────┼───────────────────┼──────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Domain Layer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Models │ │ Services │ │ Managers │ │
│ │ (model/) │ │ (engine/) │ │ (game/managers/) │ │
│ │ │ │ │ │ │ │
│ │ - GameState │ │ - Progress │ │ - Statistics │ │
│ │ - Equipment │ │ - Combat │ │ - SpeedBoost │ │
│ │ - Skills │ │ - Item │ │ - ReturnRewards │ │
│ └─────────────┘ └─────────────┘ │ - Resurrection │ │
│ │ - HallOfFame │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Data Layer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Static │ │ Storage │ │ External │ │
│ │ Data │ │ │ │ Services │ │
│ │ (data/) │ │ (storage/) │ │ │ │
│ │ │ │ │ │ - AdService │ │
│ │ - Config │ │ - SaveMgr │ │ - IAPService │ │
│ │ - Races │ │ - HallOfFame│ │ - AudioService │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## GameSessionController 매니저 구조
```
┌──────────────────────────────────────────────────────────────┐
│ GameSessionController │
│ (526 LOC) │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 핵심 책임: │ │
│ │ - 게임 루프 관리 (startNew, pause, resume) │ │
│ │ - 콜백 처리 (_onPlayerDied, _onGameComplete) │ │
│ │ - 상태 관리 (GameState, MonetizationState) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Statistics │ │ SpeedBoost │ │ Return │ │
│ │ Manager │ │ Manager │ │ Rewards │ │
│ │ (140 LOC) │ │ (190 LOC) │ │ (180 LOC) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │Resurrection │ │ HallOfFame │ │
│ │ Manager │ │ Manager │ │
│ │ (160 LOC) │ │ (130 LOC) │ │
│ └─────────────┘ └─────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
### 매니저별 책임
| 매니저 | 파일 | 책임 |
|--------|------|------|
| `GameStatisticsManager` | `game_statistics_manager.dart` | 세션/누적 통계, 레벨업/골드/처치 추적 |
| `SpeedBoostManager` | `speed_boost_manager.dart` | 광고 배속, 버프 만료 체크 |
| `ReturnRewardsManager` | `return_rewards_manager.dart` | 복귀 보상 계산, 상자 보상 적용 |
| `ResurrectionManager` | `resurrection_manager.dart` | 일반/광고 부활, 자동부활 조건 |
| `HallOfFameManager` | `hall_of_fame_manager.dart` | 명예의 전당 등록, 테스트 캐릭터 |
## 게임 루프 흐름
```
┌─────────────┐
│ startNew() │
└──────┬──────┘
┌──────────────────┐
│ ProgressLoop │◄──────┐
│ (50ms tick) │ │
└────────┬─────────┘ │
│ │
▼ │
┌──────────────────┐ │
│ ProgressService │ │
│ .tick() │ │
└────────┬─────────┘ │
│ │
┌────┴────┐ │
│ │ │
▼ ▼ │
┌───────┐ ┌───────┐ │
│ Task │ │ Quest │ │
│Process│ │/Plot │ │
└───┬───┘ └───┬───┘ │
│ │ │
└────┬────┘ │
│ │
▼ │
┌──────────────────┐ │
│ State Stream │───────┘
│ → UI Update │
└──────────────────┘
┌──────────────────┐
│ AutoSave │
│ (30초 간격) │
└──────────────────┘
```
## 데이터 흐름
```
User Action
┌─────────────────┐
│ GamePlayScreen │
│ (UI Layer) │
└────────┬────────┘
┌─────────────────┐
│ GameSession │
│ Controller │
└────────┬────────┘
┌────┴────┬────────┬────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐
│Progres│ │Stats │ │Resurre│ │HallOf │
│sLoop │ │Manager│ │ction │ │Fame │
└───┬───┘ └───────┘ │Manager│ │Manager│
│ └───────┘ └───────┘
┌─────────────────┐
│ProgressService │
│ (Game Logic) │
└────────┬────────┘
┌─────────────────┐
│ GameState │
│ (Immutable) │
└────────┬────────┘
┌─────────────────┐
│ SaveManager │
│ (Persistence) │
└─────────────────┘
```
## 원본 PQ 알고리즘 매핑
| PQ 원본 (Delphi) | 포팅 위치 |
|-----------------|----------|
| `Main.pas:MonsterTask` | `progress_service.dart:_createMonsterTask()` |
| `Main.pas:StartTimer` | `progress_loop.dart:tick()` |
| `NewGuy.pas:RerollClick` | `character_roll_service.dart` |
| `Config.dfm` 데이터 | `data/pq_config_data.dart` |

View File

@@ -1226,3 +1226,23 @@ String get elementChaos => _l('Chaos', '혼돈', 'カオス');
// 스킬 상세 정보 없음 // 스킬 상세 정보 없음
String get skillNoDetails => _l('No details', '상세 정보 없음', '詳細情報なし'); String get skillNoDetails => _l('No details', '상세 정보 없음', '詳細情報なし');
// ============================================================================
// 알림 텍스트 (Notification Texts)
// ============================================================================
String get notifyLevelUp => _l('LEVEL UP!', '레벨 업!', 'レベルアップ!');
String notifyLevel(int level) =>
_l('Level $level', '레벨 $level', 'レベル $level');
String get notifyQuestComplete =>
_l('QUEST COMPLETE!', '퀘스트 완료!', 'クエスト完了!');
String get notifyPrologueComplete =>
_l('PROLOGUE COMPLETE!', '프롤로그 완료!', 'プロローグ完了!');
String notifyActComplete(int actNumber) => _l(
'ACT $actNumber COMPLETE!',
'${actNumber}막 완료!',
'${actNumber}幕完了!',
);
String get notifyNewSpell => _l('NEW SPELL!', '새 주문!', '新しい呪文!');
String get notifyNewEquipment => _l('NEW EQUIPMENT!', '새 장비!', '新しい装備!');
String get notifyBossDefeated => _l('BOSS DEFEATED!', '보스 처치!', 'ボス撃破!');

View File

@@ -310,5 +310,168 @@
"@endingTapToSkip": { "description": "Tap to skip hint" }, "@endingTapToSkip": { "description": "Tap to skip hint" },
"endingHoldToSpeedUp": "HOLD TO SPEED UP", "endingHoldToSpeedUp": "HOLD TO SPEED UP",
"@endingHoldToSpeedUp": { "description": "Hold to speed up scrolling hint" } "@endingHoldToSpeedUp": { "description": "Hold to speed up scrolling hint" },
"menuTitle": "MENU",
"@menuTitle": { "description": "Menu panel title" },
"optionsTitle": "OPTIONS",
"@optionsTitle": { "description": "Options menu title" },
"soundTitle": "SOUND",
"@soundTitle": { "description": "Sound dialog title" },
"controlSection": "CONTROL",
"@controlSection": { "description": "Control section title" },
"infoSection": "INFO",
"@infoSection": { "description": "Info section title" },
"settingsSection": "SETTINGS",
"@settingsSection": { "description": "Settings section title" },
"saveExitSection": "SAVE / EXIT",
"@saveExitSection": { "description": "Save/Exit section title" },
"ok": "OK",
"@ok": { "description": "OK button" },
"rechargeButton": "RECHARGE",
"@rechargeButton": { "description": "Recharge button" },
"createButton": "CREATE",
"@createButton": { "description": "Create button" },
"previewTitle": "PREVIEW",
"@previewTitle": { "description": "Preview panel title" },
"nameTitle": "NAME",
"@nameTitle": { "description": "Name panel title" },
"statsTitle": "STATS",
"@statsTitle": { "description": "Stats panel title" },
"raceTitle": "RACE",
"@raceTitle": { "description": "Race panel title" },
"classSection": "CLASS",
"@classSection": { "description": "Class panel title" },
"bgmLabel": "BGM",
"@bgmLabel": { "description": "BGM volume label" },
"sfxLabel": "SFX",
"@sfxLabel": { "description": "SFX volume label" },
"hpLabel": "HP",
"@hpLabel": { "description": "HP bar label" },
"mpLabel": "MP",
"@mpLabel": { "description": "MP bar label" },
"expLabel": "EXP",
"@expLabel": { "description": "EXP bar label" },
"notifyLevelUp": "LEVEL UP!",
"@notifyLevelUp": { "description": "Level up notification title" },
"notifyLevel": "Level {level}",
"@notifyLevel": {
"description": "Level notification subtitle",
"placeholders": {
"level": { "type": "int" }
}
},
"notifyQuestComplete": "QUEST COMPLETE!",
"@notifyQuestComplete": { "description": "Quest complete notification title" },
"notifyPrologueComplete": "PROLOGUE COMPLETE!",
"@notifyPrologueComplete": { "description": "Prologue complete notification title" },
"notifyActComplete": "ACT {number} COMPLETE!",
"@notifyActComplete": {
"description": "Act complete notification title",
"placeholders": {
"number": { "type": "int" }
}
},
"notifyNewSpell": "NEW SPELL!",
"@notifyNewSpell": { "description": "New spell notification title" },
"notifyNewEquipment": "NEW EQUIPMENT!",
"@notifyNewEquipment": { "description": "New equipment notification title" },
"notifyBossDefeated": "BOSS DEFEATED!",
"@notifyBossDefeated": { "description": "Boss defeated notification title" },
"rechargeRollsTitle": "RECHARGE ROLLS",
"@rechargeRollsTitle": { "description": "Recharge rolls dialog title" },
"rechargeRollsFree": "Recharge 5 rolls for free?",
"@rechargeRollsFree": { "description": "Recharge rolls free user message" },
"rechargeRollsAd": "Watch an ad to recharge 5 rolls?",
"@rechargeRollsAd": { "description": "Recharge rolls ad message" },
"debugTitle": "DEBUG",
"@debugTitle": { "description": "Debug section title" },
"debugCheatsTitle": "DEBUG CHEATS",
"@debugCheatsTitle": { "description": "Debug cheats section title" },
"debugToolsTitle": "DEBUG TOOLS",
"@debugToolsTitle": { "description": "Debug tools section title" },
"debugDeveloperTools": "DEVELOPER TOOLS",
"@debugDeveloperTools": { "description": "Developer tools header" },
"debugSkipTask": "SKIP TASK (L+1)",
"@debugSkipTask": { "description": "Skip task cheat label" },
"debugSkipTaskDesc": "Complete task instantly",
"@debugSkipTaskDesc": { "description": "Skip task cheat description" },
"debugSkipQuest": "SKIP QUEST (Q!)",
"@debugSkipQuest": { "description": "Skip quest cheat label" },
"debugSkipQuestDesc": "Complete quest instantly",
"@debugSkipQuestDesc": { "description": "Skip quest cheat description" },
"debugSkipAct": "SKIP ACT (P!)",
"@debugSkipAct": { "description": "Skip act cheat label" },
"debugSkipActDesc": "Complete act instantly",
"@debugSkipActDesc": { "description": "Skip act cheat description" },
"debugCreateTestCharacter": "CREATE TEST CHARACTER",
"@debugCreateTestCharacter": { "description": "Create test character button" },
"debugCreateTestCharacterDesc": "Register Level 100 character to Hall of Fame",
"@debugCreateTestCharacterDesc": { "description": "Create test character description" },
"debugCreateTestCharacterTitle": "CREATE TEST CHARACTER?",
"@debugCreateTestCharacterTitle": { "description": "Create test character dialog title" },
"debugCreateTestCharacterMessage": "Current character will be converted to Level 100\nand registered to the Hall of Fame.\n\n⚠ Current save file will be deleted.\nThis action cannot be undone.",
"@debugCreateTestCharacterMessage": { "description": "Create test character confirmation message" },
"debugTurbo": "DEBUG: TURBO (20x)",
"@debugTurbo": { "description": "Debug turbo mode label" },
"debugIapPurchased": "IAP PURCHASED",
"@debugIapPurchased": { "description": "IAP purchased debug toggle" },
"debugIapPurchasedDesc": "ON: Behave as paid user (ads removed)",
"@debugIapPurchasedDesc": { "description": "IAP purchased debug description" },
"debugOfflineHours": "OFFLINE HOURS",
"@debugOfflineHours": { "description": "Offline hours debug label" },
"debugOfflineHoursDesc": "Test return rewards (applies on restart)",
"@debugOfflineHoursDesc": { "description": "Offline hours debug description" },
"debugTestCharacterDesc": "Modify current character to Level 100\nand register to the Hall of Fame.",
"@debugTestCharacterDesc": { "description": "Test character creation description" }
} }

View File

@@ -93,5 +93,57 @@
"endingHallOfFameButton": "殿堂入り", "endingHallOfFameButton": "殿堂入り",
"endingSkip": "スキップ", "endingSkip": "スキップ",
"endingTapToSkip": "タップでスキップ", "endingTapToSkip": "タップでスキップ",
"endingHoldToSpeedUp": "長押しで高速スクロール" "endingHoldToSpeedUp": "長押しで高速スクロール",
"menuTitle": "MENU",
"optionsTitle": "OPTIONS",
"soundTitle": "SOUND",
"controlSection": "CONTROL",
"infoSection": "INFO",
"settingsSection": "SETTINGS",
"saveExitSection": "SAVE / EXIT",
"ok": "OK",
"rechargeButton": "RECHARGE",
"createButton": "CREATE",
"previewTitle": "PREVIEW",
"nameTitle": "NAME",
"statsTitle": "STATS",
"raceTitle": "RACE",
"classSection": "CLASS",
"bgmLabel": "BGM",
"sfxLabel": "SFX",
"hpLabel": "HP",
"mpLabel": "MP",
"expLabel": "EXP",
"notifyLevelUp": "LEVEL UP!",
"notifyLevel": "Level {level}",
"notifyQuestComplete": "QUEST COMPLETE!",
"notifyPrologueComplete": "PROLOGUE COMPLETE!",
"notifyActComplete": "ACT {number} COMPLETE!",
"notifyNewSpell": "NEW SPELL!",
"notifyNewEquipment": "NEW EQUIPMENT!",
"notifyBossDefeated": "BOSS DEFEATED!",
"rechargeRollsTitle": "RECHARGE ROLLS",
"rechargeRollsFree": "Recharge 5 rolls for free?",
"rechargeRollsAd": "Watch an ad to recharge 5 rolls?",
"debugTitle": "DEBUG",
"debugCheatsTitle": "DEBUG CHEATS",
"debugToolsTitle": "DEBUG TOOLS",
"debugDeveloperTools": "DEVELOPER TOOLS",
"debugSkipTask": "SKIP TASK (L+1)",
"debugSkipTaskDesc": "Complete task instantly",
"debugSkipQuest": "SKIP QUEST (Q!)",
"debugSkipQuestDesc": "Complete quest instantly",
"debugSkipAct": "SKIP ACT (P!)",
"debugSkipActDesc": "Complete act instantly",
"debugCreateTestCharacter": "CREATE TEST CHARACTER",
"debugCreateTestCharacterDesc": "Register Level 100 character to Hall of Fame",
"debugCreateTestCharacterTitle": "CREATE TEST CHARACTER?",
"debugCreateTestCharacterMessage": "Current character will be converted to Level 100\nand registered to the Hall of Fame.\n\n⚠ Current save file will be deleted.\nThis action cannot be undone.",
"debugTurbo": "DEBUG: TURBO (20x)",
"debugIapPurchased": "IAP PURCHASED",
"debugIapPurchasedDesc": "ON: Behave as paid user (ads removed)",
"debugOfflineHours": "OFFLINE HOURS",
"debugOfflineHoursDesc": "Test return rewards (applies on restart)",
"debugTestCharacterDesc": "Modify current character to Level 100\nand register to the Hall of Fame."
} }

View File

@@ -93,5 +93,57 @@
"endingHallOfFameButton": "명예의 전당", "endingHallOfFameButton": "명예의 전당",
"endingSkip": "건너뛰기", "endingSkip": "건너뛰기",
"endingTapToSkip": "탭하여 건너뛰기", "endingTapToSkip": "탭하여 건너뛰기",
"endingHoldToSpeedUp": "길게 누르면 빨리 스크롤" "endingHoldToSpeedUp": "길게 누르면 빨리 스크롤",
"menuTitle": "메뉴",
"optionsTitle": "옵션",
"soundTitle": "사운드",
"controlSection": "제어",
"infoSection": "정보",
"settingsSection": "설정",
"saveExitSection": "저장 / 종료",
"ok": "확인",
"rechargeButton": "충전",
"createButton": "생성",
"previewTitle": "미리보기",
"nameTitle": "이름",
"statsTitle": "능력치",
"raceTitle": "종족",
"classSection": "직업",
"bgmLabel": "BGM",
"sfxLabel": "효과음",
"hpLabel": "HP",
"mpLabel": "MP",
"expLabel": "경험치",
"notifyLevelUp": "레벨 업!",
"notifyLevel": "레벨 {level}",
"notifyQuestComplete": "퀘스트 완료!",
"notifyPrologueComplete": "프롤로그 완료!",
"notifyActComplete": "{number}막 완료!",
"notifyNewSpell": "새 주문!",
"notifyNewEquipment": "새 장비!",
"notifyBossDefeated": "보스 처치!",
"rechargeRollsTitle": "굴리기 충전",
"rechargeRollsFree": "무료로 5회 충전하시겠습니까?",
"rechargeRollsAd": "광고를 보고 5회 충전하시겠습니까?",
"debugTitle": "디버그",
"debugCheatsTitle": "디버그 치트",
"debugToolsTitle": "디버그 도구",
"debugDeveloperTools": "개발자 도구",
"debugSkipTask": "태스크 건너뛰기 (L+1)",
"debugSkipTaskDesc": "태스크 즉시 완료",
"debugSkipQuest": "퀘스트 건너뛰기 (Q!)",
"debugSkipQuestDesc": "퀘스트 즉시 완료",
"debugSkipAct": "액트 건너뛰기 (P!)",
"debugSkipActDesc": "액트 즉시 완료",
"debugCreateTestCharacter": "테스트 캐릭터 생성",
"debugCreateTestCharacterDesc": "레벨 100 캐릭터를 명예의 전당에 등록",
"debugCreateTestCharacterTitle": "테스트 캐릭터 생성?",
"debugCreateTestCharacterMessage": "현재 캐릭터가 레벨 100으로 변환되어\n명예의 전당에 등록됩니다.\n\n⚠ 현재 세이브 파일이 삭제됩니다.\n이 작업은 되돌릴 수 없습니다.",
"debugTurbo": "디버그: 터보 (20x)",
"debugIapPurchased": "IAP 구매됨",
"debugIapPurchasedDesc": "ON: 유료 유저로 동작 (광고 제거)",
"debugOfflineHours": "오프라인 시간",
"debugOfflineHoursDesc": "복귀 보상 테스트 (재시작 시 적용)",
"debugTestCharacterDesc": "현재 캐릭터를 레벨 100으로 수정하여\n명예의 전당에 등록합니다."
} }

View File

@@ -652,6 +652,312 @@ abstract class L10n {
/// In en, this message translates to: /// In en, this message translates to:
/// **'HOLD TO SPEED UP'** /// **'HOLD TO SPEED UP'**
String get endingHoldToSpeedUp; String get endingHoldToSpeedUp;
/// Menu panel title
///
/// In en, this message translates to:
/// **'MENU'**
String get menuTitle;
/// Options menu title
///
/// In en, this message translates to:
/// **'OPTIONS'**
String get optionsTitle;
/// Sound dialog title
///
/// In en, this message translates to:
/// **'SOUND'**
String get soundTitle;
/// Control section title
///
/// In en, this message translates to:
/// **'CONTROL'**
String get controlSection;
/// Info section title
///
/// In en, this message translates to:
/// **'INFO'**
String get infoSection;
/// Settings section title
///
/// In en, this message translates to:
/// **'SETTINGS'**
String get settingsSection;
/// Save/Exit section title
///
/// In en, this message translates to:
/// **'SAVE / EXIT'**
String get saveExitSection;
/// OK button
///
/// In en, this message translates to:
/// **'OK'**
String get ok;
/// Recharge button
///
/// In en, this message translates to:
/// **'RECHARGE'**
String get rechargeButton;
/// Create button
///
/// In en, this message translates to:
/// **'CREATE'**
String get createButton;
/// Preview panel title
///
/// In en, this message translates to:
/// **'PREVIEW'**
String get previewTitle;
/// Name panel title
///
/// In en, this message translates to:
/// **'NAME'**
String get nameTitle;
/// Stats panel title
///
/// In en, this message translates to:
/// **'STATS'**
String get statsTitle;
/// Race panel title
///
/// In en, this message translates to:
/// **'RACE'**
String get raceTitle;
/// Class panel title
///
/// In en, this message translates to:
/// **'CLASS'**
String get classSection;
/// BGM volume label
///
/// In en, this message translates to:
/// **'BGM'**
String get bgmLabel;
/// SFX volume label
///
/// In en, this message translates to:
/// **'SFX'**
String get sfxLabel;
/// HP bar label
///
/// In en, this message translates to:
/// **'HP'**
String get hpLabel;
/// MP bar label
///
/// In en, this message translates to:
/// **'MP'**
String get mpLabel;
/// EXP bar label
///
/// In en, this message translates to:
/// **'EXP'**
String get expLabel;
/// Level up notification title
///
/// In en, this message translates to:
/// **'LEVEL UP!'**
String get notifyLevelUp;
/// Level notification subtitle
///
/// In en, this message translates to:
/// **'Level {level}'**
String notifyLevel(int level);
/// Quest complete notification title
///
/// In en, this message translates to:
/// **'QUEST COMPLETE!'**
String get notifyQuestComplete;
/// Prologue complete notification title
///
/// In en, this message translates to:
/// **'PROLOGUE COMPLETE!'**
String get notifyPrologueComplete;
/// Act complete notification title
///
/// In en, this message translates to:
/// **'ACT {number} COMPLETE!'**
String notifyActComplete(int number);
/// New spell notification title
///
/// In en, this message translates to:
/// **'NEW SPELL!'**
String get notifyNewSpell;
/// New equipment notification title
///
/// In en, this message translates to:
/// **'NEW EQUIPMENT!'**
String get notifyNewEquipment;
/// Boss defeated notification title
///
/// In en, this message translates to:
/// **'BOSS DEFEATED!'**
String get notifyBossDefeated;
/// Recharge rolls dialog title
///
/// In en, this message translates to:
/// **'RECHARGE ROLLS'**
String get rechargeRollsTitle;
/// Recharge rolls free user message
///
/// In en, this message translates to:
/// **'Recharge 5 rolls for free?'**
String get rechargeRollsFree;
/// Recharge rolls ad message
///
/// In en, this message translates to:
/// **'Watch an ad to recharge 5 rolls?'**
String get rechargeRollsAd;
/// Debug section title
///
/// In en, this message translates to:
/// **'DEBUG'**
String get debugTitle;
/// Debug cheats section title
///
/// In en, this message translates to:
/// **'DEBUG CHEATS'**
String get debugCheatsTitle;
/// Debug tools section title
///
/// In en, this message translates to:
/// **'DEBUG TOOLS'**
String get debugToolsTitle;
/// Developer tools header
///
/// In en, this message translates to:
/// **'DEVELOPER TOOLS'**
String get debugDeveloperTools;
/// Skip task cheat label
///
/// In en, this message translates to:
/// **'SKIP TASK (L+1)'**
String get debugSkipTask;
/// Skip task cheat description
///
/// In en, this message translates to:
/// **'Complete task instantly'**
String get debugSkipTaskDesc;
/// Skip quest cheat label
///
/// In en, this message translates to:
/// **'SKIP QUEST (Q!)'**
String get debugSkipQuest;
/// Skip quest cheat description
///
/// In en, this message translates to:
/// **'Complete quest instantly'**
String get debugSkipQuestDesc;
/// Skip act cheat label
///
/// In en, this message translates to:
/// **'SKIP ACT (P!)'**
String get debugSkipAct;
/// Skip act cheat description
///
/// In en, this message translates to:
/// **'Complete act instantly'**
String get debugSkipActDesc;
/// Create test character button
///
/// In en, this message translates to:
/// **'CREATE TEST CHARACTER'**
String get debugCreateTestCharacter;
/// Create test character description
///
/// In en, this message translates to:
/// **'Register Level 100 character to Hall of Fame'**
String get debugCreateTestCharacterDesc;
/// Create test character dialog title
///
/// In en, this message translates to:
/// **'CREATE TEST CHARACTER?'**
String get debugCreateTestCharacterTitle;
/// Create test character confirmation message
///
/// In en, this message translates to:
/// **'Current character will be converted to Level 100\nand registered to the Hall of Fame.\n\n⚠ Current save file will be deleted.\nThis action cannot be undone.'**
String get debugCreateTestCharacterMessage;
/// Debug turbo mode label
///
/// In en, this message translates to:
/// **'DEBUG: TURBO (20x)'**
String get debugTurbo;
/// IAP purchased debug toggle
///
/// In en, this message translates to:
/// **'IAP PURCHASED'**
String get debugIapPurchased;
/// IAP purchased debug description
///
/// In en, this message translates to:
/// **'ON: Behave as paid user (ads removed)'**
String get debugIapPurchasedDesc;
/// Offline hours debug label
///
/// In en, this message translates to:
/// **'OFFLINE HOURS'**
String get debugOfflineHours;
/// Offline hours debug description
///
/// In en, this message translates to:
/// **'Test return rewards (applies on restart)'**
String get debugOfflineHoursDesc;
/// Test character creation description
///
/// In en, this message translates to:
/// **'Modify current character to Level 100\nand register to the Hall of Fame.'**
String get debugTestCharacterDesc;
} }
class _L10nDelegate extends LocalizationsDelegate<L10n> { class _L10nDelegate extends LocalizationsDelegate<L10n> {

View File

@@ -297,4 +297,165 @@ class L10nEn extends L10n {
@override @override
String get endingHoldToSpeedUp => 'HOLD TO SPEED UP'; String get endingHoldToSpeedUp => 'HOLD TO SPEED UP';
@override
String get menuTitle => 'MENU';
@override
String get optionsTitle => 'OPTIONS';
@override
String get soundTitle => 'SOUND';
@override
String get controlSection => 'CONTROL';
@override
String get infoSection => 'INFO';
@override
String get settingsSection => 'SETTINGS';
@override
String get saveExitSection => 'SAVE / EXIT';
@override
String get ok => 'OK';
@override
String get rechargeButton => 'RECHARGE';
@override
String get createButton => 'CREATE';
@override
String get previewTitle => 'PREVIEW';
@override
String get nameTitle => 'NAME';
@override
String get statsTitle => 'STATS';
@override
String get raceTitle => 'RACE';
@override
String get classSection => 'CLASS';
@override
String get bgmLabel => 'BGM';
@override
String get sfxLabel => 'SFX';
@override
String get hpLabel => 'HP';
@override
String get mpLabel => 'MP';
@override
String get expLabel => 'EXP';
@override
String get notifyLevelUp => 'LEVEL UP!';
@override
String notifyLevel(int level) {
return 'Level $level';
}
@override
String get notifyQuestComplete => 'QUEST COMPLETE!';
@override
String get notifyPrologueComplete => 'PROLOGUE COMPLETE!';
@override
String notifyActComplete(int number) {
return 'ACT $number COMPLETE!';
}
@override
String get notifyNewSpell => 'NEW SPELL!';
@override
String get notifyNewEquipment => 'NEW EQUIPMENT!';
@override
String get notifyBossDefeated => 'BOSS DEFEATED!';
@override
String get rechargeRollsTitle => 'RECHARGE ROLLS';
@override
String get rechargeRollsFree => 'Recharge 5 rolls for free?';
@override
String get rechargeRollsAd => 'Watch an ad to recharge 5 rolls?';
@override
String get debugTitle => 'DEBUG';
@override
String get debugCheatsTitle => 'DEBUG CHEATS';
@override
String get debugToolsTitle => 'DEBUG TOOLS';
@override
String get debugDeveloperTools => 'DEVELOPER TOOLS';
@override
String get debugSkipTask => 'SKIP TASK (L+1)';
@override
String get debugSkipTaskDesc => 'Complete task instantly';
@override
String get debugSkipQuest => 'SKIP QUEST (Q!)';
@override
String get debugSkipQuestDesc => 'Complete quest instantly';
@override
String get debugSkipAct => 'SKIP ACT (P!)';
@override
String get debugSkipActDesc => 'Complete act instantly';
@override
String get debugCreateTestCharacter => 'CREATE TEST CHARACTER';
@override
String get debugCreateTestCharacterDesc =>
'Register Level 100 character to Hall of Fame';
@override
String get debugCreateTestCharacterTitle => 'CREATE TEST CHARACTER?';
@override
String get debugCreateTestCharacterMessage =>
'Current character will be converted to Level 100\nand registered to the Hall of Fame.\n\n⚠️ Current save file will be deleted.\nThis action cannot be undone.';
@override
String get debugTurbo => 'DEBUG: TURBO (20x)';
@override
String get debugIapPurchased => 'IAP PURCHASED';
@override
String get debugIapPurchasedDesc => 'ON: Behave as paid user (ads removed)';
@override
String get debugOfflineHours => 'OFFLINE HOURS';
@override
String get debugOfflineHoursDesc =>
'Test return rewards (applies on restart)';
@override
String get debugTestCharacterDesc =>
'Modify current character to Level 100\nand register to the Hall of Fame.';
} }

View File

@@ -297,4 +297,165 @@ class L10nJa extends L10n {
@override @override
String get endingHoldToSpeedUp => '長押しで高速スクロール'; String get endingHoldToSpeedUp => '長押しで高速スクロール';
@override
String get menuTitle => 'MENU';
@override
String get optionsTitle => 'OPTIONS';
@override
String get soundTitle => 'SOUND';
@override
String get controlSection => 'CONTROL';
@override
String get infoSection => 'INFO';
@override
String get settingsSection => 'SETTINGS';
@override
String get saveExitSection => 'SAVE / EXIT';
@override
String get ok => 'OK';
@override
String get rechargeButton => 'RECHARGE';
@override
String get createButton => 'CREATE';
@override
String get previewTitle => 'PREVIEW';
@override
String get nameTitle => 'NAME';
@override
String get statsTitle => 'STATS';
@override
String get raceTitle => 'RACE';
@override
String get classSection => 'CLASS';
@override
String get bgmLabel => 'BGM';
@override
String get sfxLabel => 'SFX';
@override
String get hpLabel => 'HP';
@override
String get mpLabel => 'MP';
@override
String get expLabel => 'EXP';
@override
String get notifyLevelUp => 'LEVEL UP!';
@override
String notifyLevel(int level) {
return 'Level $level';
}
@override
String get notifyQuestComplete => 'QUEST COMPLETE!';
@override
String get notifyPrologueComplete => 'PROLOGUE COMPLETE!';
@override
String notifyActComplete(int number) {
return 'ACT $number COMPLETE!';
}
@override
String get notifyNewSpell => 'NEW SPELL!';
@override
String get notifyNewEquipment => 'NEW EQUIPMENT!';
@override
String get notifyBossDefeated => 'BOSS DEFEATED!';
@override
String get rechargeRollsTitle => 'RECHARGE ROLLS';
@override
String get rechargeRollsFree => 'Recharge 5 rolls for free?';
@override
String get rechargeRollsAd => 'Watch an ad to recharge 5 rolls?';
@override
String get debugTitle => 'DEBUG';
@override
String get debugCheatsTitle => 'DEBUG CHEATS';
@override
String get debugToolsTitle => 'DEBUG TOOLS';
@override
String get debugDeveloperTools => 'DEVELOPER TOOLS';
@override
String get debugSkipTask => 'SKIP TASK (L+1)';
@override
String get debugSkipTaskDesc => 'Complete task instantly';
@override
String get debugSkipQuest => 'SKIP QUEST (Q!)';
@override
String get debugSkipQuestDesc => 'Complete quest instantly';
@override
String get debugSkipAct => 'SKIP ACT (P!)';
@override
String get debugSkipActDesc => 'Complete act instantly';
@override
String get debugCreateTestCharacter => 'CREATE TEST CHARACTER';
@override
String get debugCreateTestCharacterDesc =>
'Register Level 100 character to Hall of Fame';
@override
String get debugCreateTestCharacterTitle => 'CREATE TEST CHARACTER?';
@override
String get debugCreateTestCharacterMessage =>
'Current character will be converted to Level 100\nand registered to the Hall of Fame.\n\n⚠️ Current save file will be deleted.\nThis action cannot be undone.';
@override
String get debugTurbo => 'DEBUG: TURBO (20x)';
@override
String get debugIapPurchased => 'IAP PURCHASED';
@override
String get debugIapPurchasedDesc => 'ON: Behave as paid user (ads removed)';
@override
String get debugOfflineHours => 'OFFLINE HOURS';
@override
String get debugOfflineHoursDesc =>
'Test return rewards (applies on restart)';
@override
String get debugTestCharacterDesc =>
'Modify current character to Level 100\nand register to the Hall of Fame.';
} }

View File

@@ -297,4 +297,162 @@ class L10nKo extends L10n {
@override @override
String get endingHoldToSpeedUp => '길게 누르면 빨리 스크롤'; String get endingHoldToSpeedUp => '길게 누르면 빨리 스크롤';
@override
String get menuTitle => '메뉴';
@override
String get optionsTitle => '옵션';
@override
String get soundTitle => '사운드';
@override
String get controlSection => '제어';
@override
String get infoSection => '정보';
@override
String get settingsSection => '설정';
@override
String get saveExitSection => '저장 / 종료';
@override
String get ok => '확인';
@override
String get rechargeButton => '충전';
@override
String get createButton => '생성';
@override
String get previewTitle => '미리보기';
@override
String get nameTitle => '이름';
@override
String get statsTitle => '능력치';
@override
String get raceTitle => '종족';
@override
String get classSection => '직업';
@override
String get bgmLabel => 'BGM';
@override
String get sfxLabel => '효과음';
@override
String get hpLabel => 'HP';
@override
String get mpLabel => 'MP';
@override
String get expLabel => '경험치';
@override
String get notifyLevelUp => '레벨 업!';
@override
String notifyLevel(int level) {
return '레벨 $level';
}
@override
String get notifyQuestComplete => '퀘스트 완료!';
@override
String get notifyPrologueComplete => '프롤로그 완료!';
@override
String notifyActComplete(int number) {
return '$number막 완료!';
}
@override
String get notifyNewSpell => '새 주문!';
@override
String get notifyNewEquipment => '새 장비!';
@override
String get notifyBossDefeated => '보스 처치!';
@override
String get rechargeRollsTitle => '굴리기 충전';
@override
String get rechargeRollsFree => '무료로 5회 충전하시겠습니까?';
@override
String get rechargeRollsAd => '광고를 보고 5회 충전하시겠습니까?';
@override
String get debugTitle => '디버그';
@override
String get debugCheatsTitle => '디버그 치트';
@override
String get debugToolsTitle => '디버그 도구';
@override
String get debugDeveloperTools => '개발자 도구';
@override
String get debugSkipTask => '태스크 건너뛰기 (L+1)';
@override
String get debugSkipTaskDesc => '태스크 즉시 완료';
@override
String get debugSkipQuest => '퀘스트 건너뛰기 (Q!)';
@override
String get debugSkipQuestDesc => '퀘스트 즉시 완료';
@override
String get debugSkipAct => '액트 건너뛰기 (P!)';
@override
String get debugSkipActDesc => '액트 즉시 완료';
@override
String get debugCreateTestCharacter => '테스트 캐릭터 생성';
@override
String get debugCreateTestCharacterDesc => '레벨 100 캐릭터를 명예의 전당에 등록';
@override
String get debugCreateTestCharacterTitle => '테스트 캐릭터 생성?';
@override
String get debugCreateTestCharacterMessage =>
'현재 캐릭터가 레벨 100으로 변환되어\n명예의 전당에 등록됩니다.\n\n⚠️ 현재 세이브 파일이 삭제됩니다.\n이 작업은 되돌릴 수 없습니다.';
@override
String get debugTurbo => '디버그: 터보 (20x)';
@override
String get debugIapPurchased => 'IAP 구매됨';
@override
String get debugIapPurchasedDesc => 'ON: 유료 유저로 동작 (광고 제거)';
@override
String get debugOfflineHours => '오프라인 시간';
@override
String get debugOfflineHoursDesc => '복귀 보상 테스트 (재시작 시 적용)';
@override
String get debugTestCharacterDesc => '현재 캐릭터를 레벨 100으로 수정하여\n명예의 전당에 등록합니다.';
} }

View File

@@ -297,4 +297,165 @@ class L10nZh extends L10n {
@override @override
String get endingHoldToSpeedUp => '长按加速滚动'; String get endingHoldToSpeedUp => '长按加速滚动';
@override
String get menuTitle => 'MENU';
@override
String get optionsTitle => 'OPTIONS';
@override
String get soundTitle => 'SOUND';
@override
String get controlSection => 'CONTROL';
@override
String get infoSection => 'INFO';
@override
String get settingsSection => 'SETTINGS';
@override
String get saveExitSection => 'SAVE / EXIT';
@override
String get ok => 'OK';
@override
String get rechargeButton => 'RECHARGE';
@override
String get createButton => 'CREATE';
@override
String get previewTitle => 'PREVIEW';
@override
String get nameTitle => 'NAME';
@override
String get statsTitle => 'STATS';
@override
String get raceTitle => 'RACE';
@override
String get classSection => 'CLASS';
@override
String get bgmLabel => 'BGM';
@override
String get sfxLabel => 'SFX';
@override
String get hpLabel => 'HP';
@override
String get mpLabel => 'MP';
@override
String get expLabel => 'EXP';
@override
String get notifyLevelUp => 'LEVEL UP!';
@override
String notifyLevel(int level) {
return 'Level $level';
}
@override
String get notifyQuestComplete => 'QUEST COMPLETE!';
@override
String get notifyPrologueComplete => 'PROLOGUE COMPLETE!';
@override
String notifyActComplete(int number) {
return 'ACT $number COMPLETE!';
}
@override
String get notifyNewSpell => 'NEW SPELL!';
@override
String get notifyNewEquipment => 'NEW EQUIPMENT!';
@override
String get notifyBossDefeated => 'BOSS DEFEATED!';
@override
String get rechargeRollsTitle => 'RECHARGE ROLLS';
@override
String get rechargeRollsFree => 'Recharge 5 rolls for free?';
@override
String get rechargeRollsAd => 'Watch an ad to recharge 5 rolls?';
@override
String get debugTitle => 'DEBUG';
@override
String get debugCheatsTitle => 'DEBUG CHEATS';
@override
String get debugToolsTitle => 'DEBUG TOOLS';
@override
String get debugDeveloperTools => 'DEVELOPER TOOLS';
@override
String get debugSkipTask => 'SKIP TASK (L+1)';
@override
String get debugSkipTaskDesc => 'Complete task instantly';
@override
String get debugSkipQuest => 'SKIP QUEST (Q!)';
@override
String get debugSkipQuestDesc => 'Complete quest instantly';
@override
String get debugSkipAct => 'SKIP ACT (P!)';
@override
String get debugSkipActDesc => 'Complete act instantly';
@override
String get debugCreateTestCharacter => 'CREATE TEST CHARACTER';
@override
String get debugCreateTestCharacterDesc =>
'Register Level 100 character to Hall of Fame';
@override
String get debugCreateTestCharacterTitle => 'CREATE TEST CHARACTER?';
@override
String get debugCreateTestCharacterMessage =>
'Current character will be converted to Level 100\nand registered to the Hall of Fame.\n\n⚠️ Current save file will be deleted.\nThis action cannot be undone.';
@override
String get debugTurbo => 'DEBUG: TURBO (20x)';
@override
String get debugIapPurchased => 'IAP PURCHASED';
@override
String get debugIapPurchasedDesc => 'ON: Behave as paid user (ads removed)';
@override
String get debugOfflineHours => 'OFFLINE HOURS';
@override
String get debugOfflineHoursDesc =>
'Test return rewards (applies on restart)';
@override
String get debugTestCharacterDesc =>
'Modify current character to Level 100\nand register to the Hall of Fame.';
} }

View File

@@ -93,5 +93,57 @@
"endingHallOfFameButton": "荣誉殿堂", "endingHallOfFameButton": "荣誉殿堂",
"endingSkip": "跳过", "endingSkip": "跳过",
"endingTapToSkip": "点击跳过", "endingTapToSkip": "点击跳过",
"endingHoldToSpeedUp": "长按加速滚动" "endingHoldToSpeedUp": "长按加速滚动",
"menuTitle": "MENU",
"optionsTitle": "OPTIONS",
"soundTitle": "SOUND",
"controlSection": "CONTROL",
"infoSection": "INFO",
"settingsSection": "SETTINGS",
"saveExitSection": "SAVE / EXIT",
"ok": "OK",
"rechargeButton": "RECHARGE",
"createButton": "CREATE",
"previewTitle": "PREVIEW",
"nameTitle": "NAME",
"statsTitle": "STATS",
"raceTitle": "RACE",
"classSection": "CLASS",
"bgmLabel": "BGM",
"sfxLabel": "SFX",
"hpLabel": "HP",
"mpLabel": "MP",
"expLabel": "EXP",
"notifyLevelUp": "LEVEL UP!",
"notifyLevel": "Level {level}",
"notifyQuestComplete": "QUEST COMPLETE!",
"notifyPrologueComplete": "PROLOGUE COMPLETE!",
"notifyActComplete": "ACT {number} COMPLETE!",
"notifyNewSpell": "NEW SPELL!",
"notifyNewEquipment": "NEW EQUIPMENT!",
"notifyBossDefeated": "BOSS DEFEATED!",
"rechargeRollsTitle": "RECHARGE ROLLS",
"rechargeRollsFree": "Recharge 5 rolls for free?",
"rechargeRollsAd": "Watch an ad to recharge 5 rolls?",
"debugTitle": "DEBUG",
"debugCheatsTitle": "DEBUG CHEATS",
"debugToolsTitle": "DEBUG TOOLS",
"debugDeveloperTools": "DEVELOPER TOOLS",
"debugSkipTask": "SKIP TASK (L+1)",
"debugSkipTaskDesc": "Complete task instantly",
"debugSkipQuest": "SKIP QUEST (Q!)",
"debugSkipQuestDesc": "Complete quest instantly",
"debugSkipAct": "SKIP ACT (P!)",
"debugSkipActDesc": "Complete act instantly",
"debugCreateTestCharacter": "CREATE TEST CHARACTER",
"debugCreateTestCharacterDesc": "Register Level 100 character to Hall of Fame",
"debugCreateTestCharacterTitle": "CREATE TEST CHARACTER?",
"debugCreateTestCharacterMessage": "Current character will be converted to Level 100\nand registered to the Hall of Fame.\n\n⚠ Current save file will be deleted.\nThis action cannot be undone.",
"debugTurbo": "DEBUG: TURBO (20x)",
"debugIapPurchased": "IAP PURCHASED",
"debugIapPurchasedDesc": "ON: Behave as paid user (ads removed)",
"debugOfflineHours": "OFFLINE HOURS",
"debugOfflineHoursDesc": "Test return rewards (applies on restart)",
"debugTestCharacterDesc": "Modify current character to Level 100\nand register to the Hall of Fame."
} }

View File

@@ -161,36 +161,107 @@ class ProgressService {
/// Tick the timer loop (equivalent to Timer1Timer in the original code). /// Tick the timer loop (equivalent to Timer1Timer in the original code).
ProgressTickResult tick(GameState state, int elapsedMillis) { ProgressTickResult tick(GameState state, int elapsedMillis) {
// 10000ms 제한: 100x 배속 (50ms * 100 = 5000ms) + 여유 공간
// 디버그 터보 모드(100x) 지원을 위해 확장
final int clamped = elapsedMillis.clamp(0, 10000).toInt(); final int clamped = elapsedMillis.clamp(0, 10000).toInt();
var progress = state.progress;
var queue = state.queue; // 1. 스킬 시스템 업데이트 (시간, 버프, MP 회복)
var nextState = state; var nextState = _updateSkillSystem(state, clamped);
var progress = nextState.progress;
var queue = nextState.queue;
// 2. 태스크 바 진행 중이면 전투 틱 처리
if (progress.task.position < progress.task.max) {
return _processTaskInProgress(nextState, clamped);
}
// 3. 태스크 완료 처리
final gain = progress.currentTask.type == TaskType.kill;
final incrementSeconds = progress.task.max ~/ 1000;
final int monsterExpReward =
progress.currentCombat?.monsterStats.expReward ?? 0;
var leveledUp = false; var leveledUp = false;
var questDone = false; var questDone = false;
var actDone = false; var actDone = false;
var gameComplete = false; var gameComplete = false;
// 스킬 시스템 시간 업데이트 (Phase 3) // 4. 킬 태스크 완료 처리
if (gain) {
final killResult = _handleKillTaskCompletion(nextState, progress, queue);
if (killResult.earlyReturn != null) return killResult.earlyReturn!;
nextState = killResult.state;
progress = killResult.progress;
queue = killResult.queue;
}
// 5. 시장/판매/구매 태스크 완료 처리
final marketResult = _handleMarketTaskCompletion(nextState, progress, queue);
if (marketResult.earlyReturn != null) return marketResult.earlyReturn!;
nextState = marketResult.state;
progress = marketResult.progress;
queue = marketResult.queue;
// 6. 경험치/레벨업 처리
if (gain && nextState.traits.level < 100 && monsterExpReward > 0) {
final expResult = _handleExpGain(nextState, progress, monsterExpReward);
nextState = expResult.state;
progress = expResult.progress;
leveledUp = expResult.leveledUp;
}
// 7. 퀘스트 진행 처리
final questResult = _handleQuestProgress(
nextState, progress, queue, gain, incrementSeconds,
);
nextState = questResult.state;
progress = questResult.progress;
queue = questResult.queue;
questDone = questResult.completed;
// 8. 플롯 진행 및 Act Boss 소환 처리
progress = _handlePlotProgress(
nextState, progress, gain, incrementSeconds,
);
// 9. 다음 태스크 디큐/생성
final dequeueResult = _handleTaskDequeue(nextState, progress, queue);
nextState = dequeueResult.state;
progress = dequeueResult.progress;
queue = dequeueResult.queue;
actDone = dequeueResult.actDone;
gameComplete = dequeueResult.gameComplete;
nextState = _recalculateEncumbrance(
nextState.copyWith(progress: progress, queue: queue),
);
return ProgressTickResult(
state: nextState,
leveledUp: leveledUp,
completedQuest: questDone,
completedAct: actDone,
gameComplete: gameComplete,
);
}
/// 스킬 시스템 업데이트 (시간, 버프 정리, MP 회복)
GameState _updateSkillSystem(GameState state, int elapsedMs) {
final skillService = SkillService(rng: state.rng); final skillService = SkillService(rng: state.rng);
var skillSystem = skillService.updateElapsedTime( var skillSystem = skillService.updateElapsedTime(
state.skillSystem, state.skillSystem,
clamped, elapsedMs,
); );
// 만료된 버프 정리
skillSystem = skillService.cleanupExpiredBuffs(skillSystem); skillSystem = skillService.cleanupExpiredBuffs(skillSystem);
var nextState = state.copyWith(skillSystem: skillSystem);
// 비전투 시 MP 회복 // 비전투 시 MP 회복
final isInCombat = final isInCombat =
progress.currentTask.type == TaskType.kill && state.progress.currentTask.type == TaskType.kill &&
progress.currentCombat != null && state.progress.currentCombat != null &&
progress.currentCombat!.isActive; state.progress.currentCombat!.isActive;
if (!isInCombat && nextState.stats.mp < nextState.stats.mpMax) { if (!isInCombat && nextState.stats.mp < nextState.stats.mpMax) {
final mpRegen = skillService.calculateMpRegen( final mpRegen = skillService.calculateMpRegen(
elapsedMs: clamped, elapsedMs: elapsedMs,
isInCombat: false, isInCombat: false,
wis: nextState.stats.wis, wis: nextState.stats.wis,
); );
@@ -205,232 +276,271 @@ class ProgressService {
} }
} }
nextState = nextState.copyWith(skillSystem: skillSystem); return nextState;
}
// Advance task bar if still running. /// 태스크 진행 중 처리 (전투 틱 포함)
if (progress.task.position < progress.task.max) { ProgressTickResult _processTaskInProgress(GameState state, int elapsedMs) {
final uncapped = progress.task.position + clamped; var progress = state.progress;
final int newTaskPos = uncapped > progress.task.max final uncapped = progress.task.position + elapsedMs;
? progress.task.max final int newTaskPos = uncapped > progress.task.max
: uncapped; ? progress.task.max
: uncapped;
// 킬 태스크 중 전투 진행 (CombatTickService 사용) var updatedCombat = progress.currentCombat;
var updatedCombat = progress.currentCombat; var updatedSkillSystem = state.skillSystem;
var updatedSkillSystem = nextState.skillSystem; var updatedPotionInventory = state.potionInventory;
var updatedPotionInventory = nextState.potionInventory; var nextState = state;
if (progress.currentTask.type == TaskType.kill &&
updatedCombat != null &&
updatedCombat.isActive) {
final combatTickService = CombatTickService(rng: nextState.rng);
final combatResult = combatTickService.processTick(
state: nextState,
combat: updatedCombat,
skillSystem: updatedSkillSystem,
elapsedMs: clamped,
);
updatedCombat = combatResult.combat;
updatedSkillSystem = combatResult.skillSystem;
if (combatResult.potionInventory != null) {
updatedPotionInventory = combatResult.potionInventory!;
}
// Phase 4: 플레이어 사망 체크 // 킬 태스크 중 전투 진행
if (!updatedCombat.playerStats.isAlive) { if (progress.currentTask.type == TaskType.kill &&
final monsterName = updatedCombat.monsterStats.name; updatedCombat != null &&
nextState = _processPlayerDeath( updatedCombat.isActive) {
nextState, final combatTickService = CombatTickService(rng: state.rng);
killerName: monsterName, final combatResult = combatTickService.processTick(
cause: DeathCause.monster, state: state,
); combat: updatedCombat,
return ProgressTickResult(state: nextState, playerDied: true); skillSystem: updatedSkillSystem,
} elapsedMs: elapsedMs,
);
updatedCombat = combatResult.combat;
updatedSkillSystem = combatResult.skillSystem;
if (combatResult.potionInventory != null) {
updatedPotionInventory = combatResult.potionInventory!;
} }
progress = progress.copyWith( // 플레이어 사망 체크
task: progress.task.copyWith(position: newTaskPos), if (!updatedCombat.playerStats.isAlive) {
currentCombat: updatedCombat, final monsterName = updatedCombat.monsterStats.name;
); nextState = _processPlayerDeath(
nextState = _recalculateEncumbrance( state,
nextState.copyWith( killerName: monsterName,
progress: progress, cause: DeathCause.monster,
skillSystem: updatedSkillSystem, );
potionInventory: updatedPotionInventory, return ProgressTickResult(state: nextState, playerDied: true);
), }
);
return ProgressTickResult(state: nextState);
} }
final gain = progress.currentTask.type == TaskType.kill; progress = progress.copyWith(
final incrementSeconds = progress.task.max ~/ 1000; task: progress.task.copyWith(position: newTaskPos),
currentCombat: updatedCombat,
// 몬스터 경험치 미리 저장 (currentCombat이 null되기 전) );
final int monsterExpReward = nextState = _recalculateEncumbrance(
progress.currentCombat?.monsterStats.expReward ?? 0; state.copyWith(
// 킬 태스크 완료 시 전투 결과 반영 및 전리품 획득
if (gain) {
// 전투 결과에 따라 플레이어 HP 업데이트 + 전투 후 회복
final combat = progress.currentCombat;
if (combat != null && combat.isActive) {
// 전투 중 남은 HP
final remainingHp = combat.playerStats.hpCurrent;
final maxHp = combat.playerStats.hpMax;
// 전투 승리 시 HP 회복 (50% + CON/2 + 클래스 패시브)
// 아이들 게임 특성상 전투 사이 HP가 회복되어야 지속 플레이 가능
final conBonus = nextState.stats.con ~/ 2;
var healAmount = (maxHp * 0.5).round() + conBonus;
// 클래스 패시브: 전투 후 HP 회복 (예: Garbage Collector +5%)
final klass = ClassData.findById(nextState.traits.classId);
if (klass != null) {
final postCombatHealRate =
klass.getPassiveValue(ClassPassiveType.postCombatHeal);
if (postCombatHealRate > 0) {
healAmount += (maxHp * postCombatHealRate).round();
}
}
final newHp = (remainingHp + healAmount).clamp(0, maxHp);
nextState = nextState.copyWith(
stats: nextState.stats.copyWith(hpCurrent: newHp),
);
}
// 전리품 획득 (원본 Main.pas:625-630)
final lootResult = _winLoot(nextState);
nextState = lootResult.state;
// 물약 드랍 시 전투 로그에 이벤트 추가
var combatForReset = progress.currentCombat;
if (lootResult.droppedPotion != null && combatForReset != null) {
final potionDropEvent = CombatEvent.potionDrop(
timestamp: nextState.skillSystem.elapsedMs,
potionName: lootResult.droppedPotion!.name,
isHp: lootResult.droppedPotion!.isHpPotion,
);
final updatedEvents = [...combatForReset.recentEvents, potionDropEvent];
combatForReset = combatForReset.copyWith(
recentEvents: updatedEvents.length > 10
? updatedEvents.sublist(updatedEvents.length - 10)
: updatedEvents,
);
progress = progress.copyWith(currentCombat: combatForReset);
}
// Boss 승리 처리: 시네마틱 트리거
if (progress.pendingActCompletion) {
// Act Boss를 처치했으므로 시네마틱 재생
final cinematicEntries = pq_logic.interplotCinematic(
config,
nextState.rng,
nextState.traits.level,
progress.plotStageCount,
);
queue = QueueState(entries: [...queue.entries, ...cinematicEntries]);
progress = progress.copyWith(
currentCombat: null,
monstersKilled: progress.monstersKilled + 1,
pendingActCompletion: false, // Boss 처치 완료
);
} else {
// 일반 전투 종료
progress = progress.copyWith(
currentCombat: null,
monstersKilled: progress.monstersKilled + 1,
);
}
nextState = nextState.copyWith(
progress: progress, progress: progress,
queue: queue, skillSystem: updatedSkillSystem,
potionInventory: updatedPotionInventory,
),
);
return ProgressTickResult(state: nextState);
}
/// 킬 태스크 완료 처리 (HP 회복, 전리품, 보스 처리)
({
GameState state,
ProgressState progress,
QueueState queue,
ProgressTickResult? earlyReturn,
}) _handleKillTaskCompletion(
GameState state,
ProgressState progress,
QueueState queue,
) {
var nextState = state;
// 전투 후 HP 회복
final combat = progress.currentCombat;
if (combat != null && combat.isActive) {
final remainingHp = combat.playerStats.hpCurrent;
final maxHp = combat.playerStats.hpMax;
final conBonus = nextState.stats.con ~/ 2;
var healAmount = (maxHp * 0.5).round() + conBonus;
final klass = ClassData.findById(nextState.traits.classId);
if (klass != null) {
final postCombatHealRate =
klass.getPassiveValue(ClassPassiveType.postCombatHeal);
if (postCombatHealRate > 0) {
healAmount += (maxHp * postCombatHealRate).round();
}
}
final newHp = (remainingHp + healAmount).clamp(0, maxHp);
nextState = nextState.copyWith(
stats: nextState.stats.copyWith(hpCurrent: newHp),
); );
}
// 최종 보스 처치 체크 // 전리품 획득
if (progress.finalBossState == FinalBossState.fighting) { final lootResult = _winLoot(nextState);
// 글리치 갓 처치 완료 - 게임 클리어 nextState = lootResult.state;
progress = progress.copyWith(finalBossState: FinalBossState.defeated);
nextState = nextState.copyWith(progress: progress);
// completeAct를 호출하여 게임 완료 처리 // 물약 드랍 로그 추가
final actResult = completeAct(nextState); var combatForReset = progress.currentCombat;
nextState = actResult.state; if (lootResult.droppedPotion != null && combatForReset != null) {
final potionDropEvent = CombatEvent.potionDrop(
timestamp: nextState.skillSystem.elapsedMs,
potionName: lootResult.droppedPotion!.name,
isHp: lootResult.droppedPotion!.isHpPotion,
);
final updatedEvents = [...combatForReset.recentEvents, potionDropEvent];
combatForReset = combatForReset.copyWith(
recentEvents: updatedEvents.length > 10
? updatedEvents.sublist(updatedEvents.length - 10)
: updatedEvents,
);
progress = progress.copyWith(currentCombat: combatForReset);
}
return ProgressTickResult( // Boss 승리 처리
state: nextState, if (progress.pendingActCompletion) {
leveledUp: false, final cinematicEntries = pq_logic.interplotCinematic(
completedQuest: false, config,
nextState.rng,
nextState.traits.level,
progress.plotStageCount,
);
queue = QueueState(entries: [...queue.entries, ...cinematicEntries]);
progress = progress.copyWith(
currentCombat: null,
monstersKilled: progress.monstersKilled + 1,
pendingActCompletion: false,
);
} else {
progress = progress.copyWith(
currentCombat: null,
monstersKilled: progress.monstersKilled + 1,
);
}
nextState = nextState.copyWith(progress: progress, queue: queue);
// 최종 보스 처치 체크
if (progress.finalBossState == FinalBossState.fighting) {
progress = progress.copyWith(finalBossState: FinalBossState.defeated);
nextState = nextState.copyWith(progress: progress);
final actResult = completeAct(nextState);
return (
state: actResult.state,
progress: actResult.state.progress,
queue: actResult.state.queue,
earlyReturn: ProgressTickResult(
state: actResult.state,
completedAct: true, completedAct: true,
gameComplete: true, gameComplete: true,
); ),
} );
} }
// 시장/판매/구매 태스크 완료 시 처리 (MarketService 사용) return (
final marketService = MarketService(rng: nextState.rng); state: nextState,
progress: progress,
queue: queue,
earlyReturn: null,
);
}
/// 시장/판매/구매 태스크 완료 처리
({
GameState state,
ProgressState progress,
QueueState queue,
ProgressTickResult? earlyReturn,
}) _handleMarketTaskCompletion(
GameState state,
ProgressState progress,
QueueState queue,
) {
var nextState = state;
final marketService = MarketService(rng: state.rng);
final taskType = progress.currentTask.type; final taskType = progress.currentTask.type;
if (taskType == TaskType.buying) { if (taskType == TaskType.buying) {
// 장비 구매 완료 (원본 631-634)
nextState = marketService.completeBuying(nextState); nextState = marketService.completeBuying(nextState);
progress = nextState.progress; progress = nextState.progress;
} else if (taskType == TaskType.market || taskType == TaskType.sell) { } else if (taskType == TaskType.market || taskType == TaskType.sell) {
// 시장 도착 또는 판매 완료 (원본 635-649)
final sellResult = marketService.processSell(nextState); final sellResult = marketService.processSell(nextState);
nextState = sellResult.state; nextState = sellResult.state;
progress = nextState.progress; progress = nextState.progress;
queue = nextState.queue; queue = nextState.queue;
// 판매 중이면 다른 로직 건너뛰기
if (sellResult.continuesSelling) { if (sellResult.continuesSelling) {
nextState = _recalculateEncumbrance( nextState = _recalculateEncumbrance(
nextState.copyWith(progress: progress, queue: queue), nextState.copyWith(progress: progress, queue: queue),
); );
return ProgressTickResult( return (
state: nextState, state: nextState,
leveledUp: false, progress: progress,
completedQuest: false, queue: queue,
completedAct: false, earlyReturn: ProgressTickResult(state: nextState),
); );
} }
} }
// Gain XP / level up (몬스터 경험치 기반) return (
// 최대 레벨(100) 제한: 100레벨에서는 더 이상 레벨업하지 않음 state: nextState,
if (gain && nextState.traits.level < 100 && monsterExpReward > 0) { progress: progress,
// 종족 경험치 배율 적용 (예: Byte Human +5%, Callback Seraph +3%) queue: queue,
final race = RaceData.findById(nextState.traits.raceId); earlyReturn: null,
final expMultiplier = race?.expMultiplier ?? 1.0; );
final adjustedExp = (monsterExpReward * expMultiplier).round(); }
final newExpPos = progress.exp.position + adjustedExp;
// 레벨업 체크 (경험치가 필요량 이상일 때) /// 경험치 획득 및 레벨업 처리
if (newExpPos >= progress.exp.max) { ({GameState state, ProgressState progress, bool leveledUp}) _handleExpGain(
// 초과 경험치 계산 GameState state,
final overflowExp = newExpPos - progress.exp.max; ProgressState progress,
nextState = _levelUp(nextState); int monsterExpReward,
leveledUp = true; ) {
progress = nextState.progress; var nextState = state;
var leveledUp = false;
// 초과 경험치를 다음 레벨에 적용 final race = RaceData.findById(nextState.traits.raceId);
if (overflowExp > 0 && nextState.traits.level < 100) { final expMultiplier = race?.expMultiplier ?? 1.0;
progress = progress.copyWith( final adjustedExp = (monsterExpReward * expMultiplier).round();
exp: progress.exp.copyWith(position: overflowExp), final newExpPos = progress.exp.position + adjustedExp;
);
} if (newExpPos >= progress.exp.max) {
} else { final overflowExp = newExpPos - progress.exp.max;
nextState = _levelUp(nextState);
leveledUp = true;
progress = nextState.progress;
if (overflowExp > 0 && nextState.traits.level < 100) {
progress = progress.copyWith( progress = progress.copyWith(
exp: progress.exp.copyWith(position: newExpPos), exp: progress.exp.copyWith(position: overflowExp),
); );
} }
} else {
progress = progress.copyWith(
exp: progress.exp.copyWith(position: newExpPos),
);
} }
// Advance quest bar after Act I. return (state: nextState, progress: progress, leveledUp: leveledUp);
}
/// 퀘스트 진행 처리
({
GameState state,
ProgressState progress,
QueueState queue,
bool completed,
}) _handleQuestProgress(
GameState state,
ProgressState progress,
QueueState queue,
bool gain,
int incrementSeconds,
) {
var nextState = state;
var questDone = false;
final canQuestProgress = final canQuestProgress =
gain && gain &&
progress.plotStageCount > 1 && progress.plotStageCount > 1 &&
progress.questCount > 0 && progress.questCount > 0 &&
progress.quest.max > 0; progress.quest.max > 0;
if (canQuestProgress) { if (canQuestProgress) {
if (progress.quest.position + incrementSeconds >= progress.quest.max) { if (progress.quest.position + incrementSeconds >= progress.quest.max) {
nextState = completeQuest(nextState); nextState = completeQuest(nextState);
@@ -446,19 +556,31 @@ class ProgressService {
} }
} }
// 플롯(plot) 바가 완료되면 Act Boss 소환 return (
// (개선: Boss 처치 → 시네마틱 → Act 전환 순서) state: nextState,
progress: progress,
queue: queue,
completed: questDone,
);
}
/// 플롯 진행 및 Act Boss 소환 처리
ProgressState _handlePlotProgress(
GameState state,
ProgressState progress,
bool gain,
int incrementSeconds,
) {
if (gain && if (gain &&
progress.plot.max > 0 && progress.plot.max > 0 &&
progress.plot.position >= progress.plot.max && progress.plot.position >= progress.plot.max &&
!progress.pendingActCompletion) { !progress.pendingActCompletion) {
// Act Boss 소환 및 플래그 설정
final actProgressionService = ActProgressionService(config: config); final actProgressionService = ActProgressionService(config: config);
final actBoss = actProgressionService.createActBoss(nextState); final actBoss = actProgressionService.createActBoss(state);
progress = progress.copyWith( return progress.copyWith(
plot: progress.plot.copyWith(position: 0), // Plot bar 리셋 plot: progress.plot.copyWith(position: 0),
currentCombat: actBoss, currentCombat: actBoss,
pendingActCompletion: true, // Boss 처치 대기 플래그 pendingActCompletion: true,
); );
} else if (progress.currentTask.type != TaskType.load && } else if (progress.currentTask.type != TaskType.load &&
progress.plot.max > 0 && progress.plot.max > 0 &&
@@ -467,12 +589,29 @@ class ProgressService {
final int newPlotPos = uncappedPlot > progress.plot.max final int newPlotPos = uncappedPlot > progress.plot.max
? progress.plot.max ? progress.plot.max
: uncappedPlot; : uncappedPlot;
progress = progress.copyWith( return progress.copyWith(
plot: progress.plot.copyWith(position: newPlotPos), plot: progress.plot.copyWith(position: newPlotPos),
); );
} }
return progress;
}
/// 태스크 디큐 및 생성 처리
({
GameState state,
ProgressState progress,
QueueState queue,
bool actDone,
bool gameComplete,
}) _handleTaskDequeue(
GameState state,
ProgressState progress,
QueueState queue,
) {
var nextState = state;
var actDone = false;
var gameComplete = false;
// Dequeue next scripted task if available.
final dq = pq_logic.dequeue(progress, queue); final dq = pq_logic.dequeue(progress, queue);
if (dq != null) { if (dq != null) {
progress = dq.progress.copyWith( progress = dq.progress.copyWith(
@@ -480,7 +619,6 @@ class ProgressService {
); );
queue = dq.queue; queue = dq.queue;
// plot 타입이 dequeue 되면 completeAct 실행 (원본 Main.pas 로직)
if (dq.kind == QueueKind.plot) { if (dq.kind == QueueKind.plot) {
nextState = nextState.copyWith(progress: progress, queue: queue); nextState = nextState.copyWith(progress: progress, queue: queue);
final actResult = completeAct(nextState); final actResult = completeAct(nextState);
@@ -491,22 +629,17 @@ class ProgressService {
queue = nextState.queue; queue = nextState.queue;
} }
} else { } else {
// 큐가 비어있으면 새 태스크 생성 (원본 Dequeue 667-684줄)
nextState = nextState.copyWith(progress: progress, queue: queue); nextState = nextState.copyWith(progress: progress, queue: queue);
final newTaskResult = _generateNextTask(nextState); final newTaskResult = _generateNextTask(nextState);
progress = newTaskResult.progress; progress = newTaskResult.progress;
queue = newTaskResult.queue; queue = newTaskResult.queue;
} }
nextState = _recalculateEncumbrance( return (
nextState.copyWith(progress: progress, queue: queue),
);
return ProgressTickResult(
state: nextState, state: nextState,
leveledUp: leveledUp, progress: progress,
completedQuest: questDone, queue: queue,
completedAct: actDone, actDone: actDone,
gameComplete: gameComplete, gameComplete: gameComplete,
); );
} }
@@ -519,120 +652,155 @@ class ProgressService {
final queue = state.queue; final queue = state.queue;
final oldTaskType = progress.currentTask.type; final oldTaskType = progress.currentTask.type;
// 1. Encumbrance가 가득 찼으면 시장으로 이동 (원본 667-669줄) // 1. Encumbrance 초과 시 시장 이동
if (progress.encumbrance.position >= progress.encumbrance.max && if (_shouldGoToMarket(progress)) {
progress.encumbrance.max > 0) { return _createMarketTask(progress, queue);
final taskResult = pq_logic.startTask(
progress,
l10n.taskHeadingToMarket(),
4 * 1000,
);
progress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.market,
),
currentCombat: null, // 비전투 태스크이므로 전투 상태 초기화
);
return (progress: progress, queue: queue);
} }
// 2. kill/heading/buying 태스크가 아니었으면 heading 또는 buying 태스크 실행 // 2. 전환 태스크 (buying/heading)
// (원본 670-677줄) - buying 완료 후 무한 루프 방지 if (_needsTransitionTask(oldTaskType)) {
if (oldTaskType != TaskType.kill && return _createTransitionTask(state, progress, queue);
oldTaskType != TaskType.neutral &&
oldTaskType != TaskType.buying) {
// Gold가 충분하면 장비 구매 (Common 장비 가격 기준)
// 실제 구매 가격과 동일한 공식 사용: level * 50
final gold = state.inventory.gold;
final equipPrice = state.traits.level * 50; // Common 장비 1개 가격
if (gold > equipPrice) {
final taskResult = pq_logic.startTask(
progress,
l10n.taskUpgradingHardware(),
5 * 1000,
);
progress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.buying,
),
currentCombat: null, // 비전투 태스크이므로 전투 상태 초기화
);
return (progress: progress, queue: queue);
}
// Gold가 부족하면 전장으로 이동 (원본 674-676줄)
final taskResult = pq_logic.startTask(
progress,
l10n.taskEnteringDebugZone(),
4 * 1000,
);
progress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.neutral,
),
currentCombat: null, // 비전투 태스크이므로 전투 상태 초기화
);
return (progress: progress, queue: queue);
} }
// 3. Act Boss 리트라이 체크 // 3. Act Boss 리트라이
// pendingActCompletion이 true면 Act Boss 재소환
if (state.progress.pendingActCompletion) { if (state.progress.pendingActCompletion) {
final actProgressionService = ActProgressionService(config: config); return _createActBossRetryTask(state, progress, queue);
final actBoss = actProgressionService.createActBoss(state); }
final combatCalculator = CombatCalculator(rng: state.rng);
final durationMillis = combatCalculator.estimateCombatDurationMs(
player: actBoss.playerStats,
monster: actBoss.monsterStats,
);
// 4. 최종 보스 전투
if (state.progress.finalBossState == FinalBossState.fighting &&
!state.progress.isInBossLevelingMode) {
if (state.progress.bossLevelingEndTime != null) {
progress = progress.copyWith(clearBossLevelingEndTime: true);
}
final actProgressionService = ActProgressionService(config: config);
return actProgressionService.startFinalBossFight(state, progress, queue);
}
// 5. 일반 몬스터 전투
return _createMonsterTask(state, progress, queue);
}
/// 시장 이동 조건 확인
bool _shouldGoToMarket(ProgressState progress) {
return progress.encumbrance.position >= progress.encumbrance.max &&
progress.encumbrance.max > 0;
}
/// 전환 태스크 필요 여부 확인
bool _needsTransitionTask(TaskType oldTaskType) {
return oldTaskType != TaskType.kill &&
oldTaskType != TaskType.neutral &&
oldTaskType != TaskType.buying;
}
/// 시장 이동 태스크 생성
({ProgressState progress, QueueState queue}) _createMarketTask(
ProgressState progress,
QueueState queue,
) {
final taskResult = pq_logic.startTask(
progress,
l10n.taskHeadingToMarket(),
4 * 1000,
);
final updatedProgress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.market,
),
currentCombat: null,
);
return (progress: updatedProgress, queue: queue);
}
/// 전환 태스크 생성 (buying 또는 heading)
({ProgressState progress, QueueState queue}) _createTransitionTask(
GameState state,
ProgressState progress,
QueueState queue,
) {
final gold = state.inventory.gold;
final equipPrice = state.traits.level * 50;
// Gold 충분 시 장비 구매
if (gold > equipPrice) {
final taskResult = pq_logic.startTask( final taskResult = pq_logic.startTask(
progress, progress,
l10n.taskDebugging(actBoss.monsterStats.name), l10n.taskUpgradingHardware(),
durationMillis, 5 * 1000,
); );
final updatedProgress = taskResult.progress.copyWith(
progress = taskResult.progress.copyWith(
currentTask: TaskInfo( currentTask: TaskInfo(
caption: taskResult.caption, caption: taskResult.caption,
type: TaskType.kill, type: TaskType.buying,
monsterBaseName: actBoss.monsterStats.name,
monsterPart: '*', // Boss는 WinItem 드랍
monsterLevel: actBoss.monsterStats.level,
monsterGrade: MonsterGrade.boss,
monsterSize: getBossSizeForAct(state.progress.plotStageCount),
), ),
currentCombat: actBoss, currentCombat: null,
); );
return (progress: updatedProgress, queue: queue);
return (progress: progress, queue: queue);
} }
// 4. 최종 보스 전투 체크 // Gold 부족 시 전장 이동
// finalBossState == fighting이면 Glitch God 스폰 final taskResult = pq_logic.startTask(
// 단, 레벨링 모드 중이면 일반 몬스터로 레벨업 후 재도전 progress,
if (state.progress.finalBossState == FinalBossState.fighting) { l10n.taskEnteringDebugZone(),
if (state.progress.isInBossLevelingMode) { 4 * 1000,
// 레벨링 모드: 일반 몬스터 전투로 대체 (아래 MonsterTask로 진행) );
} else { final updatedProgress = taskResult.progress.copyWith(
// 레벨링 모드 종료 또는 첫 도전: 보스전 시작 currentTask: TaskInfo(
// 레벨링 모드가 끝났으면 타이머 초기화 caption: taskResult.caption,
if (state.progress.bossLevelingEndTime != null) { type: TaskType.neutral,
progress = progress.copyWith(clearBossLevelingEndTime: true); ),
} currentCombat: null,
final actProgressionService = ActProgressionService(config: config); );
return actProgressionService.startFinalBossFight(state, progress, queue); return (progress: updatedProgress, queue: queue);
} }
}
// 5. MonsterTask 실행 (원본 678-684줄) /// Act Boss 재도전 태스크 생성
({ProgressState progress, QueueState queue}) _createActBossRetryTask(
GameState state,
ProgressState progress,
QueueState queue,
) {
final actProgressionService = ActProgressionService(config: config);
final actBoss = actProgressionService.createActBoss(state);
final combatCalculator = CombatCalculator(rng: state.rng);
final durationMillis = combatCalculator.estimateCombatDurationMs(
player: actBoss.playerStats,
monster: actBoss.monsterStats,
);
final taskResult = pq_logic.startTask(
progress,
l10n.taskDebugging(actBoss.monsterStats.name),
durationMillis,
);
final updatedProgress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.kill,
monsterBaseName: actBoss.monsterStats.name,
monsterPart: '*',
monsterLevel: actBoss.monsterStats.level,
monsterGrade: MonsterGrade.boss,
monsterSize: getBossSizeForAct(state.progress.plotStageCount),
),
currentCombat: actBoss,
);
return (progress: updatedProgress, queue: queue);
}
/// 일반 몬스터 전투 태스크 생성
({ProgressState progress, QueueState queue}) _createMonsterTask(
GameState state,
ProgressState progress,
QueueState queue,
) {
final level = state.traits.level; final level = state.traits.level;
// 원본 Main.pas:548-551: 25% 확률로 Quest Monster 사용 // 퀘스트 몬스터 데이터 확인
// fQuest.Caption이 비어있지 않으면 해당 몬스터 데이터 전달
final questMonster = state.progress.currentQuestMonster; final questMonster = state.progress.currentQuestMonster;
final questMonsterData = questMonster?.monsterData; final questMonsterData = questMonster?.monsterData;
final questLevel = questMonsterData != null final questLevel = questMonsterData != null
@@ -640,6 +808,7 @@ class ProgressService {
0 0
: null; : null;
// 몬스터 생성
final monsterResult = pq_logic.monsterTask( final monsterResult = pq_logic.monsterTask(
config, config,
state.rng, state.rng,
@@ -648,8 +817,7 @@ class ProgressService {
questLevel, questLevel,
); );
// 전투용 몬스터 레벨 조정 (밸런스) // 몬스터 레벨 조정 (밸런스)
// Act별 최소 레벨과 플레이어 레벨 중 큰 값을 기준으로 ±3 범위 제한
final actMinLevel = ActMonsterLevel.forPlotStage( final actMinLevel = ActMonsterLevel.forPlotStage(
state.progress.plotStageCount, state.progress.plotStageCount,
); );
@@ -658,7 +826,7 @@ class ProgressService {
.clamp(math.max(1, baseLevel - 3), baseLevel + 3) .clamp(math.max(1, baseLevel - 3), baseLevel + 3)
.toInt(); .toInt();
// 전투 스탯 생성 (Phase 12: 몬스터 레벨 기반 페널티 적용) // 전투 스탯 생성
final playerCombatStats = CombatStats.fromStats( final playerCombatStats = CombatStats.fromStats(
stats: state.stats, stats: state.stats,
equipment: state.equipment, equipment: state.equipment,
@@ -673,13 +841,12 @@ class ProgressService {
plotStageCount: state.progress.plotStageCount, plotStageCount: state.progress.plotStageCount,
); );
// 전투 상태 초기화 // 전투 상태 및 지속시간
final combatState = CombatState.start( final combatState = CombatState.start(
playerStats: playerCombatStats, playerStats: playerCombatStats,
monsterStats: monsterCombatStats, monsterStats: monsterCombatStats,
); );
// 태스크 지속시간 계산 (CombatCalculator 기반)
final combatCalculator = CombatCalculator(rng: state.rng); final combatCalculator = CombatCalculator(rng: state.rng);
final durationMillis = combatCalculator.estimateCombatDurationMs( final durationMillis = combatCalculator.estimateCombatDurationMs(
player: playerCombatStats, player: playerCombatStats,
@@ -692,14 +859,14 @@ class ProgressService {
durationMillis, durationMillis,
); );
// 몬스터 사이즈 결정 (Act 기반, Phase 13) // 몬스터 사이즈 결정
final monsterSize = getMonsterSizeForAct( final monsterSize = getMonsterSizeForAct(
plotStageCount: state.progress.plotStageCount, plotStageCount: state.progress.plotStageCount,
grade: monsterResult.grade, grade: monsterResult.grade,
rng: state.rng, rng: state.rng,
); );
progress = taskResult.progress.copyWith( final updatedProgress = taskResult.progress.copyWith(
currentTask: TaskInfo( currentTask: TaskInfo(
caption: taskResult.caption, caption: taskResult.caption,
type: TaskType.kill, type: TaskType.kill,
@@ -712,7 +879,7 @@ class ProgressService {
currentCombat: combatState, currentCombat: combatState,
); );
return (progress: progress, queue: queue); return (progress: updatedProgress, queue: queue);
} }
/// Advances quest completion, applies reward, and enqueues next quest task. /// Advances quest completion, applies reward, and enqueues next quest task.

View File

@@ -0,0 +1,96 @@
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/core/model/equipment_item.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
/// 사망 원인 (Death Cause)
enum DeathCause {
/// 몬스터에 의한 사망
monster,
/// 자해 스킬에 의한 사망
selfDamage,
/// 환경 피해에 의한 사망
environment,
}
/// 사망 정보 (Phase 4)
///
/// 사망 시점의 정보와 상실한 아이템을 기록
class DeathInfo {
const DeathInfo({
required this.cause,
required this.killerName,
required this.lostEquipmentCount,
required this.goldAtDeath,
required this.levelAtDeath,
required this.timestamp,
this.lostItemName,
this.lostItemSlot,
this.lostItemRarity,
this.lostItem,
this.lastCombatEvents = const [],
});
/// 사망 원인
final DeathCause cause;
/// 사망시킨 몬스터/원인 이름
final String killerName;
/// 상실한 장비 개수 (0 또는 1)
final int lostEquipmentCount;
/// 제물로 바친 아이템 이름 (null이면 없음)
final String? lostItemName;
/// 제물로 바친 아이템 슬롯 (null이면 없음)
final EquipmentSlot? lostItemSlot;
/// 제물로 바친 아이템 희귀도 (null이면 없음)
final ItemRarity? lostItemRarity;
/// 상실한 장비 전체 정보 (광고 부활 시 복구용)
final EquipmentItem? lostItem;
/// 사망 시점 골드
final int goldAtDeath;
/// 사망 시점 레벨
final int levelAtDeath;
/// 사망 시각 (밀리초)
final int timestamp;
/// 사망 직전 전투 이벤트 (최대 10개)
final List<CombatEvent> lastCombatEvents;
DeathInfo copyWith({
DeathCause? cause,
String? killerName,
int? lostEquipmentCount,
String? lostItemName,
EquipmentSlot? lostItemSlot,
ItemRarity? lostItemRarity,
EquipmentItem? lostItem,
int? goldAtDeath,
int? levelAtDeath,
int? timestamp,
List<CombatEvent>? lastCombatEvents,
}) {
return DeathInfo(
cause: cause ?? this.cause,
killerName: killerName ?? this.killerName,
lostEquipmentCount: lostEquipmentCount ?? this.lostEquipmentCount,
lostItemName: lostItemName ?? this.lostItemName,
lostItemSlot: lostItemSlot ?? this.lostItemSlot,
lostItemRarity: lostItemRarity ?? this.lostItemRarity,
lostItem: lostItem ?? this.lostItem,
goldAtDeath: goldAtDeath ?? this.goldAtDeath,
levelAtDeath: levelAtDeath ?? this.levelAtDeath,
timestamp: timestamp ?? this.timestamp,
lastCombatEvents: lastCombatEvents ?? this.lastCombatEvents,
);
}
}

View File

@@ -0,0 +1,220 @@
import 'package:asciineverdie/src/core/model/equipment_item.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
/// 장비 (원본 Main.dfm Equips ListView, 11개 슬롯)
///
/// Phase 2에서 EquipmentItem 기반으로 확장됨.
/// 기존 문자열 API(weapon, shield 등)는 호환성을 위해 유지.
class Equipment {
Equipment({required this.items, required this.bestIndex})
: assert(items.length == slotCount, 'Equipment must have $slotCount items');
/// 장비 아이템 목록 (11개 슬롯)
final List<EquipmentItem> items;
/// 최고 아이템 슬롯 인덱스 (원본 Equips.Tag, 0-10)
final int bestIndex;
/// 슬롯 개수
static const slotCount = 11;
// ==========================================================================
// 문자열 API (기존 코드 호환성)
// ==========================================================================
String get weapon => items[0].name; // 0: 무기
String get shield => items[1].name; // 1: 방패
String get helm => items[2].name; // 2: 투구
String get hauberk => items[3].name; // 3: 사슬갑옷
String get brassairts => items[4].name; // 4: 상완갑
String get vambraces => items[5].name; // 5: 전완갑
String get gauntlets => items[6].name; // 6: 건틀릿
String get gambeson => items[7].name; // 7: 갬비슨
String get cuisses => items[8].name; // 8: 허벅지갑
String get greaves => items[9].name; // 9: 정강이갑
String get sollerets => items[10].name; // 10: 철제신발
// ==========================================================================
// EquipmentItem API
// ==========================================================================
EquipmentItem get weaponItem => items[0];
EquipmentItem get shieldItem => items[1];
EquipmentItem get helmItem => items[2];
EquipmentItem get hauberkItem => items[3];
EquipmentItem get brassairtsItem => items[4];
EquipmentItem get vambracesItem => items[5];
EquipmentItem get gauntletsItem => items[6];
EquipmentItem get gambesonItem => items[7];
EquipmentItem get cuissesItem => items[8];
EquipmentItem get greavesItem => items[9];
EquipmentItem get solleretsItem => items[10];
/// 모든 장비 스탯 합산
ItemStats get totalStats {
return items.fold(ItemStats.empty, (sum, item) => sum + item.stats);
}
/// 모든 장비 무게 합산
int get totalWeight {
return items.fold(0, (sum, item) => sum + item.weight);
}
/// 장착된 아이템 목록 (빈 슬롯 제외)
List<EquipmentItem> get equippedItems {
return items.where((item) => item.isNotEmpty).toList();
}
// ==========================================================================
// 팩토리 메서드
// ==========================================================================
factory Equipment.empty() {
return Equipment(
items: [
EquipmentItem.defaultWeapon(), // 0: 무기 (Keyboard)
EquipmentItem.empty(EquipmentSlot.shield), // 1: 방패
EquipmentItem.empty(EquipmentSlot.helm), // 2: 투구
EquipmentItem.empty(EquipmentSlot.hauberk), // 3: 사슬갑옷
EquipmentItem.empty(EquipmentSlot.brassairts), // 4: 상완갑
EquipmentItem.empty(EquipmentSlot.vambraces), // 5: 전완갑
EquipmentItem.empty(EquipmentSlot.gauntlets), // 6: 건틀릿
EquipmentItem.empty(EquipmentSlot.gambeson), // 7: 갬비슨
EquipmentItem.empty(EquipmentSlot.cuisses), // 8: 허벅지갑
EquipmentItem.empty(EquipmentSlot.greaves), // 9: 정강이갑
EquipmentItem.empty(EquipmentSlot.sollerets), // 10: 철제신발
],
bestIndex: 0,
);
}
/// 초보자 장비 세트 (모든 슬롯에 기본 방어구 지급)
///
/// 원본 PQ에서는 초기 장비가 없지만, 밸런스 개선을 위해
/// Act 1 완료 전에도 기본 방어력을 제공.
factory Equipment.withStarterGear() {
return Equipment(
items: [
EquipmentItem.defaultWeapon(), // 0: 무기 (Keyboard)
_starterArmor('Old Mouse', EquipmentSlot.shield, def: 2),
_starterArmor('Cardboard Box', EquipmentSlot.helm, def: 1),
_starterArmor('Worn T-Shirt', EquipmentSlot.hauberk, def: 3),
_starterArmor('Rubber Band', EquipmentSlot.brassairts, def: 1),
_starterArmor('Wristwatch', EquipmentSlot.vambraces, def: 1),
_starterArmor('Fingerless Gloves', EquipmentSlot.gauntlets, def: 1),
_starterArmor('Hoodie', EquipmentSlot.gambeson, def: 2),
_starterArmor('Jeans', EquipmentSlot.cuisses, def: 2),
_starterArmor('Knee Pads', EquipmentSlot.greaves, def: 1),
_starterArmor('Sneakers', EquipmentSlot.sollerets, def: 1),
],
bestIndex: 0,
);
}
/// 초보자 방어구 생성 헬퍼
static EquipmentItem _starterArmor(
String name,
EquipmentSlot slot, {
required int def,
}) {
return EquipmentItem(
name: name,
slot: slot,
level: 1,
weight: 1,
stats: ItemStats(def: def),
rarity: ItemRarity.common,
);
}
/// 레거시 문자열 기반 생성자 (세이브 파일 호환용)
factory Equipment.fromStrings({
required String weapon,
required String shield,
required String helm,
required String hauberk,
required String brassairts,
required String vambraces,
required String gauntlets,
required String gambeson,
required String cuisses,
required String greaves,
required String sollerets,
required int bestIndex,
}) {
return Equipment(
items: [
_itemFromString(weapon, EquipmentSlot.weapon),
_itemFromString(shield, EquipmentSlot.shield),
_itemFromString(helm, EquipmentSlot.helm),
_itemFromString(hauberk, EquipmentSlot.hauberk),
_itemFromString(brassairts, EquipmentSlot.brassairts),
_itemFromString(vambraces, EquipmentSlot.vambraces),
_itemFromString(gauntlets, EquipmentSlot.gauntlets),
_itemFromString(gambeson, EquipmentSlot.gambeson),
_itemFromString(cuisses, EquipmentSlot.cuisses),
_itemFromString(greaves, EquipmentSlot.greaves),
_itemFromString(sollerets, EquipmentSlot.sollerets),
],
bestIndex: bestIndex,
);
}
/// 문자열에서 기본 EquipmentItem 생성 (레거시 호환)
static EquipmentItem _itemFromString(String name, EquipmentSlot slot) {
if (name.isEmpty) {
return EquipmentItem.empty(slot);
}
// 레거시 아이템: 레벨 1, Common, 기본 스탯
return EquipmentItem(
name: name,
slot: slot,
level: 1,
weight: 5,
stats: ItemStats.empty,
rarity: ItemRarity.common,
);
}
// ==========================================================================
// 유틸리티 메서드
// ==========================================================================
/// 인덱스로 슬롯 이름 가져오기 (기존 API 호환)
String getByIndex(int index) {
if (index < 0 || index >= slotCount) return '';
return items[index].name;
}
/// 인덱스로 EquipmentItem 가져오기
EquipmentItem getItemByIndex(int index) {
if (index < 0 || index >= slotCount) {
return EquipmentItem.empty(EquipmentSlot.weapon);
}
return items[index];
}
/// 인덱스로 슬롯 값 설정 (문자열, 기존 API 호환)
Equipment setByIndex(int index, String value) {
if (index < 0 || index >= slotCount) return this;
final slot = EquipmentSlot.values[index];
final newItem = _itemFromString(value, slot);
return setItemByIndex(index, newItem);
}
/// 인덱스로 EquipmentItem 설정
Equipment setItemByIndex(int index, EquipmentItem item) {
if (index < 0 || index >= slotCount) return this;
final newItems = List<EquipmentItem>.from(items);
newItems[index] = item;
return Equipment(items: newItems, bestIndex: bestIndex);
}
Equipment copyWith({List<EquipmentItem>? items, int? bestIndex}) {
return Equipment(
items: items ?? List<EquipmentItem>.from(this.items),
bestIndex: bestIndex ?? this.bestIndex,
);
}
}

View File

@@ -17,16 +17,23 @@ class EquipmentItem with _$EquipmentItem {
const factory EquipmentItem({ const factory EquipmentItem({
/// 아이템 이름 (예: "Flaming Sword of Doom") /// 아이템 이름 (예: "Flaming Sword of Doom")
required String name, required String name,
/// 장착 슬롯 /// 장착 슬롯
// ignore: invalid_annotation_target
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson) @JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
required EquipmentSlot slot, required EquipmentSlot slot,
/// 아이템 레벨 /// 아이템 레벨
required int level, required int level,
/// 무게 (STR 기반 휴대 제한용) /// 무게 (STR 기반 휴대 제한용)
required int weight, required int weight,
/// 아이템 스탯 보정치 /// 아이템 스탯 보정치
required ItemStats stats, required ItemStats stats,
/// 희귀도 /// 희귀도
// ignore: invalid_annotation_target
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson) @JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
required ItemRarity rarity, required ItemRarity rarity,
}) = _EquipmentItem; }) = _EquipmentItem;

View File

@@ -1,21 +1,35 @@
import 'dart:collection'; /// 게임 상태 모듈 (Game State Module)
///
/// 하위 파일들을 re-export하여 기존 import 호환성 유지
library;
import 'package:asciineverdie/src/core/animation/monster_size.dart'; export 'death_info.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart'; export 'equipment_container.dart';
import 'package:asciineverdie/src/core/model/combat_state.dart'; export 'inventory.dart';
import 'package:asciineverdie/src/core/model/equipment_item.dart'; export 'progress_state.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart'; export 'queue_state.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart'; export 'skill_book.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart'; export 'skill_system_state.dart';
export 'stats.dart';
export 'task_info.dart';
export 'traits.dart';
import 'package:asciineverdie/src/core/model/death_info.dart';
import 'package:asciineverdie/src/core/model/equipment_container.dart';
import 'package:asciineverdie/src/core/model/inventory.dart';
import 'package:asciineverdie/src/core/model/potion.dart'; import 'package:asciineverdie/src/core/model/potion.dart';
import 'package:asciineverdie/src/core/model/skill.dart'; import 'package:asciineverdie/src/core/model/progress_state.dart';
import 'package:asciineverdie/src/core/model/skill_slots.dart'; import 'package:asciineverdie/src/core/model/queue_state.dart';
import 'package:asciineverdie/src/core/model/skill_book.dart';
import 'package:asciineverdie/src/core/model/skill_system_state.dart';
import 'package:asciineverdie/src/core/model/stats.dart';
import 'package:asciineverdie/src/core/model/traits.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart'; import 'package:asciineverdie/src/core/util/deterministic_random.dart';
/// Minimal skeletal state to mirror Progress Quest structures. /// 게임 전체 상태 (Game State)
/// ///
/// Logic will be ported faithfully from the Delphi source; this file only /// Progress Quest 구조를 미러링하는 최소 스켈레톤 상태.
/// defines containers and helpers for deterministic RNG. /// 로직은 Delphi 소스에서 충실하게 포팅됨.
class GameState { class GameState {
GameState({ GameState({
required DeterministicRandom rng, required DeterministicRandom rng,
@@ -118,880 +132,3 @@ class GameState {
); );
} }
} }
/// 사망 정보 (Phase 4)
///
/// 사망 시점의 정보와 상실한 아이템을 기록
class DeathInfo {
const DeathInfo({
required this.cause,
required this.killerName,
required this.lostEquipmentCount,
required this.goldAtDeath,
required this.levelAtDeath,
required this.timestamp,
this.lostItemName,
this.lostItemSlot,
this.lostItemRarity,
this.lostItem,
this.lastCombatEvents = const [],
});
/// 사망 원인
final DeathCause cause;
/// 사망시킨 몬스터/원인 이름
final String killerName;
/// 상실한 장비 개수 (0 또는 1)
final int lostEquipmentCount;
/// 제물로 바친 아이템 이름 (null이면 없음)
final String? lostItemName;
/// 제물로 바친 아이템 슬롯 (null이면 없음)
final EquipmentSlot? lostItemSlot;
/// 제물로 바친 아이템 희귀도 (null이면 없음)
final ItemRarity? lostItemRarity;
/// 상실한 장비 전체 정보 (광고 부활 시 복구용)
final EquipmentItem? lostItem;
/// 사망 시점 골드
final int goldAtDeath;
/// 사망 시점 레벨
final int levelAtDeath;
/// 사망 시각 (밀리초)
final int timestamp;
/// 사망 직전 전투 이벤트 (최대 10개)
final List<CombatEvent> lastCombatEvents;
DeathInfo copyWith({
DeathCause? cause,
String? killerName,
int? lostEquipmentCount,
String? lostItemName,
EquipmentSlot? lostItemSlot,
ItemRarity? lostItemRarity,
EquipmentItem? lostItem,
int? goldAtDeath,
int? levelAtDeath,
int? timestamp,
List<CombatEvent>? lastCombatEvents,
}) {
return DeathInfo(
cause: cause ?? this.cause,
killerName: killerName ?? this.killerName,
lostEquipmentCount: lostEquipmentCount ?? this.lostEquipmentCount,
lostItemName: lostItemName ?? this.lostItemName,
lostItemSlot: lostItemSlot ?? this.lostItemSlot,
lostItemRarity: lostItemRarity ?? this.lostItemRarity,
lostItem: lostItem ?? this.lostItem,
goldAtDeath: goldAtDeath ?? this.goldAtDeath,
levelAtDeath: levelAtDeath ?? this.levelAtDeath,
timestamp: timestamp ?? this.timestamp,
lastCombatEvents: lastCombatEvents ?? this.lastCombatEvents,
);
}
}
/// 사망 원인
enum DeathCause {
/// 몬스터에 의한 사망
monster,
/// 자해 스킬에 의한 사망
selfDamage,
/// 환경 피해에 의한 사망
environment,
}
/// 스킬 시스템 상태 (Phase 3)
///
/// 스킬 쿨타임, 활성 버프, 게임 경과 시간, 장착 스킬 등을 관리
class SkillSystemState {
const SkillSystemState({
required this.skillStates,
required this.activeBuffs,
required this.elapsedMs,
this.equippedSkills = const SkillSlots(),
this.globalCooldownEndMs = 0,
});
/// 글로벌 쿨타임 (GCD) 상수: 1500ms
static const int globalCooldownDuration = 1500;
/// 스킬별 쿨타임 상태
final List<SkillState> skillStates;
/// 현재 활성화된 버프 목록
final List<ActiveBuff> activeBuffs;
/// 게임 진행 경과 시간 (밀리초, 스킬 쿨타임 계산용)
final int elapsedMs;
/// 장착된 스킬 슬롯 (타입별 제한 있음)
final SkillSlots equippedSkills;
/// 글로벌 쿨타임 종료 시점 (elapsedMs 기준)
final int globalCooldownEndMs;
/// GCD가 활성화 중인지 확인
bool get isGlobalCooldownActive => elapsedMs < globalCooldownEndMs;
/// 남은 GCD 시간 (ms)
int get remainingGlobalCooldown =>
isGlobalCooldownActive ? globalCooldownEndMs - elapsedMs : 0;
factory SkillSystemState.empty() => const SkillSystemState(
skillStates: [],
activeBuffs: [],
elapsedMs: 0,
equippedSkills: SkillSlots(),
globalCooldownEndMs: 0,
);
/// 특정 스킬 상태 가져오기
SkillState? getSkillState(String skillId) {
for (final state in skillStates) {
if (state.skillId == skillId) return state;
}
return null;
}
/// 버프 효과 합산 (동일 버프는 중복 적용 안 됨)
({double atkMod, double defMod, double criMod, double evasionMod})
get totalBuffModifiers {
double atkMod = 0;
double defMod = 0;
double criMod = 0;
double evasionMod = 0;
final seenBuffIds = <String>{};
for (final buff in activeBuffs) {
if (seenBuffIds.contains(buff.effect.id)) continue;
seenBuffIds.add(buff.effect.id);
if (!buff.isExpired(elapsedMs)) {
atkMod += buff.effect.atkModifier;
defMod += buff.effect.defModifier;
criMod += buff.effect.criRateModifier;
evasionMod += buff.effect.evasionModifier;
}
}
return (
atkMod: atkMod,
defMod: defMod,
criMod: criMod,
evasionMod: evasionMod,
);
}
SkillSystemState copyWith({
List<SkillState>? skillStates,
List<ActiveBuff>? activeBuffs,
int? elapsedMs,
SkillSlots? equippedSkills,
int? globalCooldownEndMs,
}) {
return SkillSystemState(
skillStates: skillStates ?? this.skillStates,
activeBuffs: activeBuffs ?? this.activeBuffs,
elapsedMs: elapsedMs ?? this.elapsedMs,
equippedSkills: equippedSkills ?? this.equippedSkills,
globalCooldownEndMs: globalCooldownEndMs ?? this.globalCooldownEndMs,
);
}
/// GCD 시작 (스킬 사용 후 호출)
SkillSystemState startGlobalCooldown() {
return copyWith(globalCooldownEndMs: elapsedMs + globalCooldownDuration);
}
}
/// 태스크 타입 (원본 fTask.Caption 값들에 대응)
enum TaskType {
neutral, // heading 등 일반 이동
kill, // 몬스터 처치
load, // 로딩/초기화
plot, // 플롯 진행
market, // 시장으로 이동 중
sell, // 아이템 판매 중
buying, // 장비 구매 중
}
class TaskInfo {
const TaskInfo({
required this.caption,
required this.type,
this.monsterBaseName,
this.monsterPart,
this.monsterLevel,
this.monsterGrade,
this.monsterSize,
});
final String caption;
final TaskType type;
/// 킬 태스크의 몬스터 기본 이름 (형용사 제외, 예: "Goblin")
final String? monsterBaseName;
/// 킬 태스크의 전리품 부위 (예: "claw", "tail", "*"는 WinItem)
final String? monsterPart;
/// 킬 태스크의 몬스터 레벨 (전투 스탯 계산용)
final int? monsterLevel;
/// 킬 태스크의 몬스터 등급 (Normal/Elite/Boss)
final MonsterGrade? monsterGrade;
/// 킬 태스크의 몬스터 사이즈 (애니메이션 크기 결정용, Act 기반)
final MonsterSize? monsterSize;
factory TaskInfo.empty() =>
const TaskInfo(caption: '', type: TaskType.neutral);
TaskInfo copyWith({
String? caption,
TaskType? type,
String? monsterBaseName,
String? monsterPart,
int? monsterLevel,
MonsterGrade? monsterGrade,
MonsterSize? monsterSize,
}) {
return TaskInfo(
caption: caption ?? this.caption,
type: type ?? this.type,
monsterBaseName: monsterBaseName ?? this.monsterBaseName,
monsterPart: monsterPart ?? this.monsterPart,
monsterLevel: monsterLevel ?? this.monsterLevel,
monsterGrade: monsterGrade ?? this.monsterGrade,
monsterSize: monsterSize ?? this.monsterSize,
);
}
}
class Traits {
const Traits({
required this.name,
required this.race,
required this.klass,
required this.level,
required this.motto,
required this.guild,
this.raceId = '',
this.classId = '',
});
final String name;
/// 종족 표시 이름 (예: "Kernel Giant")
final String race;
/// 클래스 표시 이름 (예: "Bug Hunter")
final String klass;
final int level;
final String motto;
final String guild;
/// 종족 ID (Phase 5, 예: "kernel_giant")
final String raceId;
/// 클래스 ID (Phase 5, 예: "bug_hunter")
final String classId;
factory Traits.empty() => const Traits(
name: '',
race: '',
klass: '',
level: 1,
motto: '',
guild: '',
raceId: '',
classId: '',
);
Traits copyWith({
String? name,
String? race,
String? klass,
int? level,
String? motto,
String? guild,
String? raceId,
String? classId,
}) {
return Traits(
name: name ?? this.name,
race: race ?? this.race,
klass: klass ?? this.klass,
level: level ?? this.level,
motto: motto ?? this.motto,
guild: guild ?? this.guild,
raceId: raceId ?? this.raceId,
classId: classId ?? this.classId,
);
}
}
class Stats {
const Stats({
required this.str,
required this.con,
required this.dex,
required this.intelligence,
required this.wis,
required this.cha,
required this.hpMax,
required this.mpMax,
this.hpCurrent,
this.mpCurrent,
});
final int str;
final int con;
final int dex;
final int intelligence;
final int wis;
final int cha;
final int hpMax;
final int mpMax;
/// 현재 HP (null이면 hpMax와 동일)
final int? hpCurrent;
/// 현재 MP (null이면 mpMax와 동일)
final int? mpCurrent;
/// 실제 현재 HP 값
int get hp => hpCurrent ?? hpMax;
/// 실제 현재 MP 값
int get mp => mpCurrent ?? mpMax;
factory Stats.empty() => const Stats(
str: 0,
con: 0,
dex: 0,
intelligence: 0,
wis: 0,
cha: 0,
hpMax: 0,
mpMax: 0,
);
Stats copyWith({
int? str,
int? con,
int? dex,
int? intelligence,
int? wis,
int? cha,
int? hpMax,
int? mpMax,
int? hpCurrent,
int? mpCurrent,
}) {
return Stats(
str: str ?? this.str,
con: con ?? this.con,
dex: dex ?? this.dex,
intelligence: intelligence ?? this.intelligence,
wis: wis ?? this.wis,
cha: cha ?? this.cha,
hpMax: hpMax ?? this.hpMax,
mpMax: mpMax ?? this.mpMax,
hpCurrent: hpCurrent ?? this.hpCurrent,
mpCurrent: mpCurrent ?? this.mpCurrent,
);
}
}
class InventoryEntry {
const InventoryEntry({required this.name, required this.count});
final String name;
final int count;
InventoryEntry copyWith({String? name, int? count}) {
return InventoryEntry(name: name ?? this.name, count: count ?? this.count);
}
}
class Inventory {
const Inventory({required this.gold, required this.items});
final int gold;
final List<InventoryEntry> items;
/// 초기 골드 1000 지급 (캐릭터 생성 시)
factory Inventory.empty() => const Inventory(gold: 1000, items: []);
Inventory copyWith({int? gold, List<InventoryEntry>? items}) {
return Inventory(gold: gold ?? this.gold, items: items ?? this.items);
}
}
/// 장비 (원본 Main.dfm Equips ListView, 11개 슬롯)
///
/// Phase 2에서 EquipmentItem 기반으로 확장됨.
/// 기존 문자열 API(weapon, shield 등)는 호환성을 위해 유지.
class Equipment {
Equipment({required this.items, required this.bestIndex})
: assert(items.length == slotCount, 'Equipment must have $slotCount items');
/// 장비 아이템 목록 (11개 슬롯)
final List<EquipmentItem> items;
/// 최고 아이템 슬롯 인덱스 (원본 Equips.Tag, 0-10)
final int bestIndex;
/// 슬롯 개수
static const slotCount = 11;
// ============================================================================
// 문자열 API (기존 코드 호환성)
// ============================================================================
String get weapon => items[0].name; // 0: 무기
String get shield => items[1].name; // 1: 방패
String get helm => items[2].name; // 2: 투구
String get hauberk => items[3].name; // 3: 사슬갑옷
String get brassairts => items[4].name; // 4: 상완갑
String get vambraces => items[5].name; // 5: 전완갑
String get gauntlets => items[6].name; // 6: 건틀릿
String get gambeson => items[7].name; // 7: 갬비슨
String get cuisses => items[8].name; // 8: 허벅지갑
String get greaves => items[9].name; // 9: 정강이갑
String get sollerets => items[10].name; // 10: 철제신발
// ============================================================================
// EquipmentItem API
// ============================================================================
EquipmentItem get weaponItem => items[0];
EquipmentItem get shieldItem => items[1];
EquipmentItem get helmItem => items[2];
EquipmentItem get hauberkItem => items[3];
EquipmentItem get brassairtsItem => items[4];
EquipmentItem get vambracesItem => items[5];
EquipmentItem get gauntletsItem => items[6];
EquipmentItem get gambesonItem => items[7];
EquipmentItem get cuissesItem => items[8];
EquipmentItem get greavesItem => items[9];
EquipmentItem get solleretsItem => items[10];
/// 모든 장비 스탯 합산
ItemStats get totalStats {
return items.fold(ItemStats.empty, (sum, item) => sum + item.stats);
}
/// 모든 장비 무게 합산
int get totalWeight {
return items.fold(0, (sum, item) => sum + item.weight);
}
/// 장착된 아이템 목록 (빈 슬롯 제외)
List<EquipmentItem> get equippedItems {
return items.where((item) => item.isNotEmpty).toList();
}
// ============================================================================
// 팩토리 메서드
// ============================================================================
factory Equipment.empty() {
return Equipment(
items: [
EquipmentItem.defaultWeapon(), // 0: 무기 (Keyboard)
EquipmentItem.empty(EquipmentSlot.shield), // 1: 방패
EquipmentItem.empty(EquipmentSlot.helm), // 2: 투구
EquipmentItem.empty(EquipmentSlot.hauberk), // 3: 사슬갑옷
EquipmentItem.empty(EquipmentSlot.brassairts), // 4: 상완갑
EquipmentItem.empty(EquipmentSlot.vambraces), // 5: 전완갑
EquipmentItem.empty(EquipmentSlot.gauntlets), // 6: 건틀릿
EquipmentItem.empty(EquipmentSlot.gambeson), // 7: 갬비슨
EquipmentItem.empty(EquipmentSlot.cuisses), // 8: 허벅지갑
EquipmentItem.empty(EquipmentSlot.greaves), // 9: 정강이갑
EquipmentItem.empty(EquipmentSlot.sollerets), // 10: 철제신발
],
bestIndex: 0,
);
}
/// 초보자 장비 세트 (모든 슬롯에 기본 방어구 지급)
///
/// 원본 PQ에서는 초기 장비가 없지만, 밸런스 개선을 위해
/// Act 1 완료 전에도 기본 방어력을 제공.
factory Equipment.withStarterGear() {
return Equipment(
items: [
EquipmentItem.defaultWeapon(), // 0: 무기 (Keyboard)
_starterArmor('Old Mouse', EquipmentSlot.shield, def: 2),
_starterArmor('Cardboard Box', EquipmentSlot.helm, def: 1),
_starterArmor('Worn T-Shirt', EquipmentSlot.hauberk, def: 3),
_starterArmor('Rubber Band', EquipmentSlot.brassairts, def: 1),
_starterArmor('Wristwatch', EquipmentSlot.vambraces, def: 1),
_starterArmor('Fingerless Gloves', EquipmentSlot.gauntlets, def: 1),
_starterArmor('Hoodie', EquipmentSlot.gambeson, def: 2),
_starterArmor('Jeans', EquipmentSlot.cuisses, def: 2),
_starterArmor('Knee Pads', EquipmentSlot.greaves, def: 1),
_starterArmor('Sneakers', EquipmentSlot.sollerets, def: 1),
],
bestIndex: 0,
);
}
/// 초보자 방어구 생성 헬퍼
static EquipmentItem _starterArmor(
String name,
EquipmentSlot slot, {
required int def,
}) {
return EquipmentItem(
name: name,
slot: slot,
level: 1,
weight: 1,
stats: ItemStats(def: def),
rarity: ItemRarity.common,
);
}
/// 레거시 문자열 기반 생성자 (세이브 파일 호환용)
factory Equipment.fromStrings({
required String weapon,
required String shield,
required String helm,
required String hauberk,
required String brassairts,
required String vambraces,
required String gauntlets,
required String gambeson,
required String cuisses,
required String greaves,
required String sollerets,
required int bestIndex,
}) {
return Equipment(
items: [
_itemFromString(weapon, EquipmentSlot.weapon),
_itemFromString(shield, EquipmentSlot.shield),
_itemFromString(helm, EquipmentSlot.helm),
_itemFromString(hauberk, EquipmentSlot.hauberk),
_itemFromString(brassairts, EquipmentSlot.brassairts),
_itemFromString(vambraces, EquipmentSlot.vambraces),
_itemFromString(gauntlets, EquipmentSlot.gauntlets),
_itemFromString(gambeson, EquipmentSlot.gambeson),
_itemFromString(cuisses, EquipmentSlot.cuisses),
_itemFromString(greaves, EquipmentSlot.greaves),
_itemFromString(sollerets, EquipmentSlot.sollerets),
],
bestIndex: bestIndex,
);
}
/// 문자열에서 기본 EquipmentItem 생성 (레거시 호환)
static EquipmentItem _itemFromString(String name, EquipmentSlot slot) {
if (name.isEmpty) {
return EquipmentItem.empty(slot);
}
// 레거시 아이템: 레벨 1, Common, 기본 스탯
return EquipmentItem(
name: name,
slot: slot,
level: 1,
weight: 5,
stats: ItemStats.empty,
rarity: ItemRarity.common,
);
}
// ============================================================================
// 유틸리티 메서드
// ============================================================================
/// 인덱스로 슬롯 이름 가져오기 (기존 API 호환)
String getByIndex(int index) {
if (index < 0 || index >= slotCount) return '';
return items[index].name;
}
/// 인덱스로 EquipmentItem 가져오기
EquipmentItem getItemByIndex(int index) {
if (index < 0 || index >= slotCount) {
return EquipmentItem.empty(EquipmentSlot.weapon);
}
return items[index];
}
/// 인덱스로 슬롯 값 설정 (문자열, 기존 API 호환)
Equipment setByIndex(int index, String value) {
if (index < 0 || index >= slotCount) return this;
final slot = EquipmentSlot.values[index];
final newItem = _itemFromString(value, slot);
return setItemByIndex(index, newItem);
}
/// 인덱스로 EquipmentItem 설정
Equipment setItemByIndex(int index, EquipmentItem item) {
if (index < 0 || index >= slotCount) return this;
final newItems = List<EquipmentItem>.from(items);
newItems[index] = item;
return Equipment(items: newItems, bestIndex: bestIndex);
}
Equipment copyWith({List<EquipmentItem>? items, int? bestIndex}) {
return Equipment(
items: items ?? List<EquipmentItem>.from(this.items),
bestIndex: bestIndex ?? this.bestIndex,
);
}
}
class SkillEntry {
const SkillEntry({required this.name, required this.rank});
final String name;
final String rank; // e.g., Roman numerals
SkillEntry copyWith({String? name, String? rank}) {
return SkillEntry(name: name ?? this.name, rank: rank ?? this.rank);
}
}
class SkillBook {
const SkillBook({required this.skills});
final List<SkillEntry> skills;
factory SkillBook.empty() => const SkillBook(skills: []);
SkillBook copyWith({List<SkillEntry>? skills}) {
return SkillBook(skills: skills ?? this.skills);
}
}
class ProgressBarState {
const ProgressBarState({required this.position, required this.max});
final int position;
final int max;
factory ProgressBarState.empty() =>
const ProgressBarState(position: 0, max: 1);
ProgressBarState copyWith({int? position, int? max}) {
return ProgressBarState(
position: position ?? this.position,
max: max ?? this.max,
);
}
}
/// 히스토리 엔트리 (Plot/Quest 진행 기록)
class HistoryEntry {
const HistoryEntry({required this.caption, required this.isComplete});
/// 표시 텍스트 (예: "Prologue", "Act I", "Exterminate the Goblins")
final String caption;
/// 완료 여부 (원본 StateIndex: 0=진행중, 1=완료)
final bool isComplete;
HistoryEntry copyWith({String? caption, bool? isComplete}) {
return HistoryEntry(
caption: caption ?? this.caption,
isComplete: isComplete ?? this.isComplete,
);
}
}
/// 현재 퀘스트 몬스터 정보 (원본 fQuest)
class QuestMonsterInfo {
const QuestMonsterInfo({
required this.monsterData,
required this.monsterIndex,
});
/// 몬스터 데이터 문자열 (예: "Goblin|3|ear")
final String monsterData;
/// 몬스터 인덱스 (Config.monsters에서의 인덱스)
final int monsterIndex;
static const empty = QuestMonsterInfo(monsterData: '', monsterIndex: -1);
}
/// 최종 보스 상태 (Final Boss State)
enum FinalBossState {
/// 최종 보스 등장 전
notSpawned,
/// 최종 보스 전투 중
fighting,
/// 최종 보스 처치 완료
defeated,
}
class ProgressState {
const ProgressState({
required this.task,
required this.quest,
required this.plot,
required this.exp,
required this.encumbrance,
required this.currentTask,
required this.plotStageCount,
required this.questCount,
this.plotHistory = const [],
this.questHistory = const [],
this.currentQuestMonster,
this.currentCombat,
this.monstersKilled = 0,
this.deathCount = 0,
this.finalBossState = FinalBossState.notSpawned,
this.pendingActCompletion = false,
this.bossLevelingEndTime,
});
final ProgressBarState task;
final ProgressBarState quest;
final ProgressBarState plot;
final ProgressBarState exp;
final ProgressBarState encumbrance;
final TaskInfo currentTask;
final int plotStageCount;
final int questCount;
/// 플롯 히스토리 (Prologue, Act I, Act II, ...)
final List<HistoryEntry> plotHistory;
/// 퀘스트 히스토리 (완료된/진행중인 퀘스트 목록)
final List<HistoryEntry> questHistory;
/// 현재 퀘스트 몬스터 정보 (Exterminate 타입용)
final QuestMonsterInfo? currentQuestMonster;
/// 현재 전투 상태 (킬 태스크 진행 중)
final CombatState? currentCombat;
/// 처치한 몬스터 수
final int monstersKilled;
/// 사망 횟수
final int deathCount;
/// 최종 보스 상태 (Act V)
final FinalBossState finalBossState;
/// Act Boss 처치 대기 중 여부 (처치 후 시네마틱 재생 트리거)
final bool pendingActCompletion;
/// 보스 사망 후 레벨링 모드 종료 시간 (milliseconds since epoch)
/// null이면 레벨링 모드 아님, 값이 있으면 해당 시간까지 레벨링
final int? bossLevelingEndTime;
factory ProgressState.empty() => ProgressState(
task: ProgressBarState.empty(),
quest: ProgressBarState.empty(),
plot: ProgressBarState.empty(),
exp: ProgressBarState.empty(),
encumbrance: ProgressBarState.empty(),
currentTask: TaskInfo.empty(),
plotStageCount: 1, // Prologue
questCount: 0,
plotHistory: const [HistoryEntry(caption: 'Prologue', isComplete: false)],
questHistory: const [],
currentQuestMonster: null,
currentCombat: null,
);
ProgressState copyWith({
ProgressBarState? task,
ProgressBarState? quest,
ProgressBarState? plot,
ProgressBarState? exp,
ProgressBarState? encumbrance,
TaskInfo? currentTask,
int? plotStageCount,
int? questCount,
List<HistoryEntry>? plotHistory,
List<HistoryEntry>? questHistory,
QuestMonsterInfo? currentQuestMonster,
CombatState? currentCombat,
int? monstersKilled,
int? deathCount,
FinalBossState? finalBossState,
bool? pendingActCompletion,
int? bossLevelingEndTime,
bool clearBossLevelingEndTime = false,
}) {
return ProgressState(
task: task ?? this.task,
quest: quest ?? this.quest,
plot: plot ?? this.plot,
exp: exp ?? this.exp,
encumbrance: encumbrance ?? this.encumbrance,
currentTask: currentTask ?? this.currentTask,
plotStageCount: plotStageCount ?? this.plotStageCount,
questCount: questCount ?? this.questCount,
plotHistory: plotHistory ?? this.plotHistory,
questHistory: questHistory ?? this.questHistory,
currentQuestMonster: currentQuestMonster ?? this.currentQuestMonster,
currentCombat: currentCombat ?? this.currentCombat,
monstersKilled: monstersKilled ?? this.monstersKilled,
deathCount: deathCount ?? this.deathCount,
finalBossState: finalBossState ?? this.finalBossState,
pendingActCompletion: pendingActCompletion ?? this.pendingActCompletion,
bossLevelingEndTime: clearBossLevelingEndTime
? null
: (bossLevelingEndTime ?? this.bossLevelingEndTime),
);
}
/// 현재 레벨링 모드인지 확인
bool get isInBossLevelingMode {
if (bossLevelingEndTime == null) return false;
return DateTime.now().millisecondsSinceEpoch < bossLevelingEndTime!;
}
}
class QueueEntry {
const QueueEntry({
required this.kind,
required this.durationMillis,
required this.caption,
this.taskType = TaskType.neutral,
});
final QueueKind kind;
final int durationMillis;
final String caption;
final TaskType taskType;
}
enum QueueKind { task, plot }
class QueueState {
QueueState({Iterable<QueueEntry>? entries})
: entries = Queue<QueueEntry>.from(entries ?? const []);
final Queue<QueueEntry> entries;
factory QueueState.empty() => QueueState(entries: const []);
QueueState copyWith({Iterable<QueueEntry>? entries}) {
return QueueState(entries: Queue<QueueEntry>.from(entries ?? this.entries));
}
}

View File

@@ -0,0 +1,28 @@
/// 인벤토리 아이템 엔트리 (Inventory Entry)
class InventoryEntry {
const InventoryEntry({required this.name, required this.count});
final String name;
final int count;
InventoryEntry copyWith({String? name, int? count}) {
return InventoryEntry(name: name ?? this.name, count: count ?? this.count);
}
}
/// 인벤토리 (Inventory)
///
/// 골드와 아이템 목록을 관리
class Inventory {
const Inventory({required this.gold, required this.items});
final int gold;
final List<InventoryEntry> items;
/// 초기 골드 1000 지급 (캐릭터 생성 시)
factory Inventory.empty() => const Inventory(gold: 1000, items: []);
Inventory copyWith({int? gold, List<InventoryEntry>? items}) {
return Inventory(gold: gold ?? this.gold, items: items ?? this.items);
}
}

View File

@@ -23,6 +23,7 @@ class MonetizationState with _$MonetizationState {
@Default(1) int undoRemaining, @Default(1) int undoRemaining,
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀) /// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
// ignore: invalid_annotation_target
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson) @JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
List<Stats>? rollHistory, List<Stats>? rollHistory,
@@ -33,6 +34,7 @@ class MonetizationState with _$MonetizationState {
int? speedBoostEndMs, int? speedBoostEndMs,
/// 마지막 플레이 시각 (복귀 보상 계산용) /// 마지막 플레이 시각 (복귀 보상 계산용)
// ignore: invalid_annotation_target
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson) @JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
DateTime? lastPlayTime, DateTime? lastPlayTime,

View File

@@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
/// 몬스터 등급 (드랍 품질 및 UI 표시에 영향) /// 몬스터 등급 (드랍 품질 및 UI 표시에 영향)
enum MonsterGrade { enum MonsterGrade {
/// 일반 몬스터 (85% 확률) /// 일반 몬스터 (85% 확률)
@@ -48,13 +46,13 @@ extension MonsterGradeExtension on MonsterGrade {
MonsterGrade.boss => 2.5, // +150% 경험치 MonsterGrade.boss => 2.5, // +150% 경험치
}; };
/// UI 표시용 색상 /// UI 표시용 색상 코드 (hex)
/// - Normal: 기본 텍스트 색상 (null 반환 → 기본 스타일 사용) /// - Normal: null (기본 스타일 사용)
/// - Elite: 파란색 (#7AA2F7) /// - Elite: 파란색 (#7AA2F7)
/// - Boss: 금색 (#E0AF68) /// - Boss: 금색 (#E0AF68)
Color? get displayColor => switch (this) { int? get displayColorCode => switch (this) {
MonsterGrade.normal => null, MonsterGrade.normal => null,
MonsterGrade.elite => const Color(0xFF7AA2F7), // MP 파랑 MonsterGrade.elite => 0xFF7AA2F7, // MP 파랑
MonsterGrade.boss => const Color(0xFFE0AF68), // 골드 MonsterGrade.boss => 0xFFE0AF68, // 골드
}; };
} }

View File

@@ -0,0 +1,192 @@
import 'package:asciineverdie/src/core/model/combat_state.dart';
import 'package:asciineverdie/src/core/model/task_info.dart';
/// 진행 바 상태 (Progress Bar State)
class ProgressBarState {
const ProgressBarState({required this.position, required this.max});
final int position;
final int max;
factory ProgressBarState.empty() =>
const ProgressBarState(position: 0, max: 1);
ProgressBarState copyWith({int? position, int? max}) {
return ProgressBarState(
position: position ?? this.position,
max: max ?? this.max,
);
}
}
/// 히스토리 엔트리 (Plot/Quest 진행 기록)
class HistoryEntry {
const HistoryEntry({required this.caption, required this.isComplete});
/// 표시 텍스트 (예: "Prologue", "Act I", "Exterminate the Goblins")
final String caption;
/// 완료 여부 (원본 StateIndex: 0=진행중, 1=완료)
final bool isComplete;
HistoryEntry copyWith({String? caption, bool? isComplete}) {
return HistoryEntry(
caption: caption ?? this.caption,
isComplete: isComplete ?? this.isComplete,
);
}
}
/// 현재 퀘스트 몬스터 정보 (원본 fQuest)
class QuestMonsterInfo {
const QuestMonsterInfo({
required this.monsterData,
required this.monsterIndex,
});
/// 몬스터 데이터 문자열 (예: "Goblin|3|ear")
final String monsterData;
/// 몬스터 인덱스 (Config.monsters에서의 인덱스)
final int monsterIndex;
static const empty = QuestMonsterInfo(monsterData: '', monsterIndex: -1);
}
/// 최종 보스 상태 (Final Boss State)
enum FinalBossState {
/// 최종 보스 등장 전
notSpawned,
/// 최종 보스 전투 중
fighting,
/// 최종 보스 처치 완료
defeated,
}
/// 진행 상태 (Progress State)
///
/// 태스크, 퀘스트, 플롯, 경험치, 무게 등의 진행 상태를 관리
class ProgressState {
const ProgressState({
required this.task,
required this.quest,
required this.plot,
required this.exp,
required this.encumbrance,
required this.currentTask,
required this.plotStageCount,
required this.questCount,
this.plotHistory = const [],
this.questHistory = const [],
this.currentQuestMonster,
this.currentCombat,
this.monstersKilled = 0,
this.deathCount = 0,
this.finalBossState = FinalBossState.notSpawned,
this.pendingActCompletion = false,
this.bossLevelingEndTime,
});
final ProgressBarState task;
final ProgressBarState quest;
final ProgressBarState plot;
final ProgressBarState exp;
final ProgressBarState encumbrance;
final TaskInfo currentTask;
final int plotStageCount;
final int questCount;
/// 플롯 히스토리 (Prologue, Act I, Act II, ...)
final List<HistoryEntry> plotHistory;
/// 퀘스트 히스토리 (완료된/진행중인 퀘스트 목록)
final List<HistoryEntry> questHistory;
/// 현재 퀘스트 몬스터 정보 (Exterminate 타입용)
final QuestMonsterInfo? currentQuestMonster;
/// 현재 전투 상태 (킬 태스크 진행 중)
final CombatState? currentCombat;
/// 처치한 몬스터 수
final int monstersKilled;
/// 사망 횟수
final int deathCount;
/// 최종 보스 상태 (Act V)
final FinalBossState finalBossState;
/// Act Boss 처치 대기 중 여부 (처치 후 시네마틱 재생 트리거)
final bool pendingActCompletion;
/// 보스 사망 후 레벨링 모드 종료 시간 (milliseconds since epoch)
/// null이면 레벨링 모드 아님, 값이 있으면 해당 시간까지 레벨링
final int? bossLevelingEndTime;
factory ProgressState.empty() => ProgressState(
task: ProgressBarState.empty(),
quest: ProgressBarState.empty(),
plot: ProgressBarState.empty(),
exp: ProgressBarState.empty(),
encumbrance: ProgressBarState.empty(),
currentTask: TaskInfo.empty(),
plotStageCount: 1, // Prologue
questCount: 0,
plotHistory: const [HistoryEntry(caption: 'Prologue', isComplete: false)],
questHistory: const [],
currentQuestMonster: null,
currentCombat: null,
);
ProgressState copyWith({
ProgressBarState? task,
ProgressBarState? quest,
ProgressBarState? plot,
ProgressBarState? exp,
ProgressBarState? encumbrance,
TaskInfo? currentTask,
int? plotStageCount,
int? questCount,
List<HistoryEntry>? plotHistory,
List<HistoryEntry>? questHistory,
QuestMonsterInfo? currentQuestMonster,
CombatState? currentCombat,
int? monstersKilled,
int? deathCount,
FinalBossState? finalBossState,
bool? pendingActCompletion,
int? bossLevelingEndTime,
bool clearBossLevelingEndTime = false,
}) {
return ProgressState(
task: task ?? this.task,
quest: quest ?? this.quest,
plot: plot ?? this.plot,
exp: exp ?? this.exp,
encumbrance: encumbrance ?? this.encumbrance,
currentTask: currentTask ?? this.currentTask,
plotStageCount: plotStageCount ?? this.plotStageCount,
questCount: questCount ?? this.questCount,
plotHistory: plotHistory ?? this.plotHistory,
questHistory: questHistory ?? this.questHistory,
currentQuestMonster: currentQuestMonster ?? this.currentQuestMonster,
currentCombat: currentCombat ?? this.currentCombat,
monstersKilled: monstersKilled ?? this.monstersKilled,
deathCount: deathCount ?? this.deathCount,
finalBossState: finalBossState ?? this.finalBossState,
pendingActCompletion: pendingActCompletion ?? this.pendingActCompletion,
bossLevelingEndTime: clearBossLevelingEndTime
? null
: (bossLevelingEndTime ?? this.bossLevelingEndTime),
);
}
/// 현재 레벨링 모드인지 확인
bool get isInBossLevelingMode {
if (bossLevelingEndTime == null) return false;
return DateTime.now().millisecondsSinceEpoch < bossLevelingEndTime!;
}
}

View File

@@ -0,0 +1,37 @@
import 'dart:collection';
import 'package:asciineverdie/src/core/model/task_info.dart';
/// 큐 종류 (Queue Kind)
enum QueueKind { task, plot }
/// 큐 엔트리 (Queue Entry)
class QueueEntry {
const QueueEntry({
required this.kind,
required this.durationMillis,
required this.caption,
this.taskType = TaskType.neutral,
});
final QueueKind kind;
final int durationMillis;
final String caption;
final TaskType taskType;
}
/// 큐 상태 (Queue State)
///
/// 대기 중인 태스크/플롯 이벤트 큐를 관리
class QueueState {
QueueState({Iterable<QueueEntry>? entries})
: entries = Queue<QueueEntry>.from(entries ?? const []);
final Queue<QueueEntry> entries;
factory QueueState.empty() => QueueState(entries: const []);
QueueState copyWith({Iterable<QueueEntry>? entries}) {
return QueueState(entries: Queue<QueueEntry>.from(entries ?? this.entries));
}
}

View File

@@ -0,0 +1,26 @@
/// 스킬 엔트리 (Skill Entry)
class SkillEntry {
const SkillEntry({required this.name, required this.rank});
final String name;
final String rank; // 예: 로마 숫자 (Roman numerals)
SkillEntry copyWith({String? name, String? rank}) {
return SkillEntry(name: name ?? this.name, rank: rank ?? this.rank);
}
}
/// 스킬북 (Skill Book)
///
/// 캐릭터가 보유한 스킬 목록을 관리
class SkillBook {
const SkillBook({required this.skills});
final List<SkillEntry> skills;
factory SkillBook.empty() => const SkillBook(skills: []);
SkillBook copyWith({List<SkillEntry>? skills}) {
return SkillBook(skills: skills ?? this.skills);
}
}

View File

@@ -0,0 +1,106 @@
import 'package:asciineverdie/src/core/model/skill.dart';
import 'package:asciineverdie/src/core/model/skill_slots.dart';
/// 스킬 시스템 상태 (Phase 3)
///
/// 스킬 쿨타임, 활성 버프, 게임 경과 시간, 장착 스킬 등을 관리
class SkillSystemState {
const SkillSystemState({
required this.skillStates,
required this.activeBuffs,
required this.elapsedMs,
this.equippedSkills = const SkillSlots(),
this.globalCooldownEndMs = 0,
});
/// 글로벌 쿨타임 (GCD) 상수: 1500ms
static const int globalCooldownDuration = 1500;
/// 스킬별 쿨타임 상태
final List<SkillState> skillStates;
/// 현재 활성화된 버프 목록
final List<ActiveBuff> activeBuffs;
/// 게임 진행 경과 시간 (밀리초, 스킬 쿨타임 계산용)
final int elapsedMs;
/// 장착된 스킬 슬롯 (타입별 제한 있음)
final SkillSlots equippedSkills;
/// 글로벌 쿨타임 종료 시점 (elapsedMs 기준)
final int globalCooldownEndMs;
/// GCD가 활성화 중인지 확인
bool get isGlobalCooldownActive => elapsedMs < globalCooldownEndMs;
/// 남은 GCD 시간 (ms)
int get remainingGlobalCooldown =>
isGlobalCooldownActive ? globalCooldownEndMs - elapsedMs : 0;
factory SkillSystemState.empty() => const SkillSystemState(
skillStates: [],
activeBuffs: [],
elapsedMs: 0,
equippedSkills: SkillSlots(),
globalCooldownEndMs: 0,
);
/// 특정 스킬 상태 가져오기
SkillState? getSkillState(String skillId) {
for (final state in skillStates) {
if (state.skillId == skillId) return state;
}
return null;
}
/// 버프 효과 합산 (동일 버프는 중복 적용 안 됨)
({double atkMod, double defMod, double criMod, double evasionMod})
get totalBuffModifiers {
double atkMod = 0;
double defMod = 0;
double criMod = 0;
double evasionMod = 0;
final seenBuffIds = <String>{};
for (final buff in activeBuffs) {
if (seenBuffIds.contains(buff.effect.id)) continue;
seenBuffIds.add(buff.effect.id);
if (!buff.isExpired(elapsedMs)) {
atkMod += buff.effect.atkModifier;
defMod += buff.effect.defModifier;
criMod += buff.effect.criRateModifier;
evasionMod += buff.effect.evasionModifier;
}
}
return (
atkMod: atkMod,
defMod: defMod,
criMod: criMod,
evasionMod: evasionMod,
);
}
SkillSystemState copyWith({
List<SkillState>? skillStates,
List<ActiveBuff>? activeBuffs,
int? elapsedMs,
SkillSlots? equippedSkills,
int? globalCooldownEndMs,
}) {
return SkillSystemState(
skillStates: skillStates ?? this.skillStates,
activeBuffs: activeBuffs ?? this.activeBuffs,
elapsedMs: elapsedMs ?? this.elapsedMs,
equippedSkills: equippedSkills ?? this.equippedSkills,
globalCooldownEndMs: globalCooldownEndMs ?? this.globalCooldownEndMs,
);
}
/// GCD 시작 (스킬 사용 후 호출)
SkillSystemState startGlobalCooldown() {
return copyWith(globalCooldownEndMs: elapsedMs + globalCooldownDuration);
}
}

View File

@@ -0,0 +1,75 @@
/// 캐릭터 스탯 (Stats)
///
/// 6대 능력치(STR, CON, DEX, INT, WIS, CHA)와 HP/MP를 관리
class Stats {
const Stats({
required this.str,
required this.con,
required this.dex,
required this.intelligence,
required this.wis,
required this.cha,
required this.hpMax,
required this.mpMax,
this.hpCurrent,
this.mpCurrent,
});
final int str;
final int con;
final int dex;
final int intelligence;
final int wis;
final int cha;
final int hpMax;
final int mpMax;
/// 현재 HP (null이면 hpMax와 동일)
final int? hpCurrent;
/// 현재 MP (null이면 mpMax와 동일)
final int? mpCurrent;
/// 실제 현재 HP 값
int get hp => hpCurrent ?? hpMax;
/// 실제 현재 MP 값
int get mp => mpCurrent ?? mpMax;
factory Stats.empty() => const Stats(
str: 0,
con: 0,
dex: 0,
intelligence: 0,
wis: 0,
cha: 0,
hpMax: 0,
mpMax: 0,
);
Stats copyWith({
int? str,
int? con,
int? dex,
int? intelligence,
int? wis,
int? cha,
int? hpMax,
int? mpMax,
int? hpCurrent,
int? mpCurrent,
}) {
return Stats(
str: str ?? this.str,
con: con ?? this.con,
dex: dex ?? this.dex,
intelligence: intelligence ?? this.intelligence,
wis: wis ?? this.wis,
cha: cha ?? this.cha,
hpMax: hpMax ?? this.hpMax,
mpMax: mpMax ?? this.mpMax,
hpCurrent: hpCurrent ?? this.hpCurrent,
mpCurrent: mpCurrent ?? this.mpCurrent,
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:asciineverdie/src/core/animation/monster_size.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
/// 태스크 타입 (원본 fTask.Caption 값들에 대응)
enum TaskType {
neutral, // heading 등 일반 이동
kill, // 몬스터 처치
load, // 로딩/초기화
plot, // 플롯 진행
market, // 시장으로 이동 중
sell, // 아이템 판매 중
buying, // 장비 구매 중
}
/// 태스크 정보 (Task Info)
class TaskInfo {
const TaskInfo({
required this.caption,
required this.type,
this.monsterBaseName,
this.monsterPart,
this.monsterLevel,
this.monsterGrade,
this.monsterSize,
});
final String caption;
final TaskType type;
/// 킬 태스크의 몬스터 기본 이름 (형용사 제외, 예: "Goblin")
final String? monsterBaseName;
/// 킬 태스크의 전리품 부위 (예: "claw", "tail", "*"는 WinItem)
final String? monsterPart;
/// 킬 태스크의 몬스터 레벨 (전투 스탯 계산용)
final int? monsterLevel;
/// 킬 태스크의 몬스터 등급 (Normal/Elite/Boss)
final MonsterGrade? monsterGrade;
/// 킬 태스크의 몬스터 사이즈 (애니메이션 크기 결정용, Act 기반)
final MonsterSize? monsterSize;
factory TaskInfo.empty() =>
const TaskInfo(caption: '', type: TaskType.neutral);
TaskInfo copyWith({
String? caption,
TaskType? type,
String? monsterBaseName,
String? monsterPart,
int? monsterLevel,
MonsterGrade? monsterGrade,
MonsterSize? monsterSize,
}) {
return TaskInfo(
caption: caption ?? this.caption,
type: type ?? this.type,
monsterBaseName: monsterBaseName ?? this.monsterBaseName,
monsterPart: monsterPart ?? this.monsterPart,
monsterLevel: monsterLevel ?? this.monsterLevel,
monsterGrade: monsterGrade ?? this.monsterGrade,
monsterSize: monsterSize ?? this.monsterSize,
);
}
}

View File

@@ -0,0 +1,66 @@
/// 캐릭터 특성 (Traits)
///
/// 이름, 종족, 직업, 레벨, 좌우명, 길드 정보를 포함
class Traits {
const Traits({
required this.name,
required this.race,
required this.klass,
required this.level,
required this.motto,
required this.guild,
this.raceId = '',
this.classId = '',
});
final String name;
/// 종족 표시 이름 (예: "Kernel Giant")
final String race;
/// 클래스 표시 이름 (예: "Bug Hunter")
final String klass;
final int level;
final String motto;
final String guild;
/// 종족 ID (Phase 5, 예: "kernel_giant")
final String raceId;
/// 클래스 ID (Phase 5, 예: "bug_hunter")
final String classId;
factory Traits.empty() => const Traits(
name: '',
race: '',
klass: '',
level: 1,
motto: '',
guild: '',
raceId: '',
classId: '',
);
Traits copyWith({
String? name,
String? race,
String? klass,
int? level,
String? motto,
String? guild,
String? raceId,
String? classId,
}) {
return Traits(
name: name ?? this.name,
race: race ?? this.race,
klass: klass ?? this.klass,
level: level ?? this.level,
motto: motto ?? this.motto,
guild: guild ?? this.guild,
raceId: raceId ?? this.raceId,
classId: classId ?? this.classId,
);
}
}

View File

@@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
/// 알림 타입 (Notification Type) /// 알림 타입 (Notification Type)
enum NotificationType { enum NotificationType {
levelUp, // 레벨업 levelUp, // 레벨업
@@ -62,8 +64,8 @@ class NotificationService {
show( show(
GameNotification( GameNotification(
type: NotificationType.levelUp, type: NotificationType.levelUp,
title: 'LEVEL UP!', title: game_l10n.notifyLevelUp,
subtitle: 'Level $newLevel', subtitle: game_l10n.notifyLevel(newLevel),
data: {'level': newLevel}, data: {'level': newLevel},
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
@@ -75,7 +77,7 @@ class NotificationService {
show( show(
GameNotification( GameNotification(
type: NotificationType.questComplete, type: NotificationType.questComplete,
title: 'QUEST COMPLETE!', title: game_l10n.notifyQuestComplete,
subtitle: questName, subtitle: questName,
data: {'quest': questName}, data: {'quest': questName},
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
@@ -87,8 +89,8 @@ class NotificationService {
/// actNumber: 0=프롤로그, 1=Act I, 2=Act II, ... /// actNumber: 0=프롤로그, 1=Act I, 2=Act II, ...
void showActComplete(int actNumber) { void showActComplete(int actNumber) {
final title = actNumber == 0 final title = actNumber == 0
? 'PROLOGUE COMPLETE!' ? game_l10n.notifyPrologueComplete
: 'ACT $actNumber COMPLETE!'; : game_l10n.notifyActComplete(actNumber);
show( show(
GameNotification( GameNotification(
type: NotificationType.actComplete, type: NotificationType.actComplete,
@@ -103,7 +105,7 @@ class NotificationService {
show( show(
GameNotification( GameNotification(
type: NotificationType.newSpell, type: NotificationType.newSpell,
title: 'NEW SPELL!', title: game_l10n.notifyNewSpell,
subtitle: spellName, subtitle: spellName,
data: {'spell': spellName}, data: {'spell': spellName},
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
@@ -116,7 +118,7 @@ class NotificationService {
show( show(
GameNotification( GameNotification(
type: NotificationType.newEquipment, type: NotificationType.newEquipment,
title: 'NEW EQUIPMENT!', title: game_l10n.notifyNewEquipment,
subtitle: equipmentName, subtitle: equipmentName,
data: {'equipment': equipmentName, 'slot': slot}, data: {'equipment': equipmentName, 'slot': slot},
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
@@ -129,7 +131,7 @@ class NotificationService {
show( show(
GameNotification( GameNotification(
type: NotificationType.bossDefeat, type: NotificationType.bossDefeat,
title: 'BOSS DEFEATED!', title: game_l10n.notifyBossDefeated,
subtitle: bossName, subtitle: bossName,
data: {'boss': bossName}, data: {'boss': bossName},
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),

View File

@@ -217,9 +217,9 @@ class _AnimationPanel extends StatelessWidget {
children: [ children: [
const Icon(Icons.auto_awesome, color: RetroColors.gold, size: 18), const Icon(Icons.auto_awesome, color: RetroColors.gold, size: 18),
const SizedBox(width: 10), const SizedBox(width: 10),
const Text( Text(
'ASCII NEVER DIE', L10n.of(context).appTitle.toUpperCase(),
style: TextStyle( style: const TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 14, fontSize: 14,
color: RetroColors.gold, color: RetroColors.gold,
@@ -272,7 +272,7 @@ class _ActionButtons extends StatelessWidget {
final l10n = L10n.of(context); final l10n = L10n.of(context);
return RetroPanel( return RetroPanel(
title: 'MENU', title: L10n.of(context).menuTitle,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,

View File

@@ -551,150 +551,135 @@ class _GamePlayScreenState extends State<GamePlayScreen>
return const Scaffold(body: Center(child: CircularProgressIndicator())); return const Scaffold(body: Center(child: CircularProgressIndicator()));
} }
// 로케일 변경 시 전체 위젯 트리 강제 리빌드를 위한 Key
final localeKey = ValueKey(game_l10n.currentGameLocale); final localeKey = ValueKey(game_l10n.currentGameLocale);
// 캐로셀 레이아웃 사용 여부 확인
if (_shouldUseCarouselLayout(context)) { if (_shouldUseCarouselLayout(context)) {
return NotificationOverlay( return _buildMobileLayout(context, state, localeKey);
key: localeKey,
notificationService: _notificationService,
child: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _onPopInvoked();
if (shouldPop && context.mounted) {
await widget.controller.pause(saveOnStop: false);
if (context.mounted) {
Navigator.of(context).pop();
}
}
},
child: Stack(
children: [
MobileCarouselLayout(
state: state,
combatLogEntries: _combatLogController.entries,
speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1,
onSpeedCycle: () {
widget.controller.loop?.cycleSpeed();
setState(() {});
},
onSetSpeed: (speed) {
widget.controller.loop?.setSpeed(speed);
setState(() {});
},
// 특수 애니메이션 중에는 일시정지 상태로 표시하지 않음
isPaused:
!widget.controller.isRunning && _specialAnimation == null,
onPauseToggle: () async {
await widget.controller.togglePause();
setState(() {});
},
onSave: _saveGameState,
onExit: () async {
final shouldExit = await _onPopInvoked();
if (shouldExit && context.mounted) {
await widget.controller.pause(saveOnStop: false);
if (context.mounted) {
Navigator.of(context).pop();
}
}
},
notificationService: _notificationService,
specialAnimation: _specialAnimation,
onLanguageChange: (locale) async {
// navigator 참조를 async gap 전에 저장
final navigator = Navigator.of(context);
// 1. 현재 상태 저장
await widget.controller.pause(saveOnStop: true);
// 2. 로케일 변경
game_l10n.setGameLocale(locale);
// 3. 화면 재생성 (전체 UI 재구성)
if (context.mounted) {
await widget.controller.resume();
navigator.pushReplacement(
MaterialPageRoute<void>(
builder: (_) => GamePlayScreen(
controller: widget.controller,
audioService: widget.audioService,
),
),
);
}
},
onDeleteSaveAndNewGame: () async {
// 게임 루프 중지
await widget.controller.pause(saveOnStop: false);
// 세이브 파일 삭제
await widget.controller.saveManager.deleteSave();
// 캐릭터 생성 화면으로 돌아가기
if (context.mounted) {
Navigator.of(context).pop();
}
},
// 사운드 설정
bgmVolume: _audioController.bgmVolume,
sfxVolume: _audioController.sfxVolume,
onBgmVolumeChange: (volume) {
_audioController.setBgmVolume(volume);
setState(() {});
},
onSfxVolumeChange: (volume) {
_audioController.setSfxVolume(volume);
setState(() {});
},
// 통계 및 도움말
onShowStatistics: () => _showStatisticsDialog(context),
onShowHelp: () => HelpDialog.show(context),
// 치트 (디버그 모드)
cheatsEnabled: widget.controller.cheatsEnabled,
onCheatTask: () => widget.controller.loop?.cheatCompleteTask(),
onCheatQuest: () =>
widget.controller.loop?.cheatCompleteQuest(),
onCheatPlot: () => widget.controller.loop?.cheatCompletePlot(),
onCreateTestCharacter: () async {
final navigator = Navigator.of(context);
final success = await widget.controller.createTestCharacter();
if (success && mounted) {
navigator.popUntil((route) => route.isFirst);
}
},
// 수익화 버프 (자동부활, 광고배속)
autoReviveEndMs: widget.controller.monetization.autoReviveEndMs,
speedBoostEndMs: widget.controller.monetization.speedBoostEndMs,
isPaidUser: widget.controller.monetization.isPaidUser,
onSpeedBoostActivate: _handleSpeedBoost,
isSpeedBoostActive: widget.controller.isSpeedBoostActive,
adSpeedMultiplier: widget.controller.adSpeedMultiplier,
has2xUnlocked: widget.controller.has2xUnlocked,
),
// 사망 오버레이
if (state.isDead && state.deathInfo != null)
DeathOverlay(
deathInfo: state.deathInfo!,
traits: state.traits,
onResurrect: _handleResurrect,
onAdRevive: _handleAdRevive,
isPaidUser: IAPService.instance.isAdRemovalPurchased,
),
// 승리 오버레이 (게임 클리어)
if (widget.controller.isComplete)
VictoryOverlay(
traits: state.traits,
stats: state.stats,
progress: state.progress,
elapsedMs: state.skillSystem.elapsedMs,
onComplete: _handleVictoryComplete,
),
],
),
),
);
} }
// 기존 데스크톱 레이아웃 (레트로 스타일) return _buildDesktopLayout(context, state, localeKey);
}
/// 모바일 캐로셀 레이아웃
Widget _buildMobileLayout(
BuildContext context,
GameState state,
ValueKey<String> localeKey,
) {
return NotificationOverlay(
key: localeKey,
notificationService: _notificationService,
child: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _onPopInvoked();
if (shouldPop && context.mounted) {
await widget.controller.pause(saveOnStop: false);
if (context.mounted) {
Navigator.of(context).pop();
}
}
},
child: Stack(
children: [
MobileCarouselLayout(
state: state,
combatLogEntries: _combatLogController.entries,
speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1,
onSpeedCycle: () {
widget.controller.loop?.cycleSpeed();
setState(() {});
},
onSetSpeed: (speed) {
widget.controller.loop?.setSpeed(speed);
setState(() {});
},
isPaused:
!widget.controller.isRunning && _specialAnimation == null,
onPauseToggle: () async {
await widget.controller.togglePause();
setState(() {});
},
onSave: _saveGameState,
onExit: () async {
final shouldExit = await _onPopInvoked();
if (shouldExit && context.mounted) {
await widget.controller.pause(saveOnStop: false);
if (context.mounted) {
Navigator.of(context).pop();
}
}
},
notificationService: _notificationService,
specialAnimation: _specialAnimation,
onLanguageChange: (locale) async {
final navigator = Navigator.of(context);
await widget.controller.pause(saveOnStop: true);
game_l10n.setGameLocale(locale);
if (context.mounted) {
await widget.controller.resume();
navigator.pushReplacement(
MaterialPageRoute<void>(
builder: (_) => GamePlayScreen(
controller: widget.controller,
audioService: widget.audioService,
),
),
);
}
},
onDeleteSaveAndNewGame: () async {
await widget.controller.pause(saveOnStop: false);
await widget.controller.saveManager.deleteSave();
if (context.mounted) {
Navigator.of(context).pop();
}
},
bgmVolume: _audioController.bgmVolume,
sfxVolume: _audioController.sfxVolume,
onBgmVolumeChange: (volume) {
_audioController.setBgmVolume(volume);
setState(() {});
},
onSfxVolumeChange: (volume) {
_audioController.setSfxVolume(volume);
setState(() {});
},
onShowStatistics: () => _showStatisticsDialog(context),
onShowHelp: () => HelpDialog.show(context),
cheatsEnabled: widget.controller.cheatsEnabled,
onCheatTask: () => widget.controller.loop?.cheatCompleteTask(),
onCheatQuest: () => widget.controller.loop?.cheatCompleteQuest(),
onCheatPlot: () => widget.controller.loop?.cheatCompletePlot(),
onCreateTestCharacter: () async {
final navigator = Navigator.of(context);
final success = await widget.controller.createTestCharacter();
if (success && mounted) {
navigator.popUntil((route) => route.isFirst);
}
},
autoReviveEndMs: widget.controller.monetization.autoReviveEndMs,
speedBoostEndMs: widget.controller.monetization.speedBoostEndMs,
isPaidUser: widget.controller.monetization.isPaidUser,
onSpeedBoostActivate: _handleSpeedBoost,
isSpeedBoostActive: widget.controller.isSpeedBoostActive,
adSpeedMultiplier: widget.controller.adSpeedMultiplier,
has2xUnlocked: widget.controller.has2xUnlocked,
),
..._buildOverlays(state),
],
),
),
);
}
/// 데스크톱 3패널 레이아웃
Widget _buildDesktopLayout(
BuildContext context,
GameState state,
ValueKey<String> localeKey,
) {
return NotificationOverlay( return NotificationOverlay(
key: localeKey, key: localeKey,
notificationService: _notificationService, notificationService: _notificationService,
@@ -710,135 +695,16 @@ class _GamePlayScreenState extends State<GamePlayScreen>
} }
} }
}, },
// 웹/데스크톱 키보드 단축키 지원
child: Focus( child: Focus(
autofocus: true, autofocus: true,
onKeyEvent: (node, event) => _handleKeyboardShortcut(event, context), onKeyEvent: (node, event) => _handleKeyboardShortcut(event, context),
child: Scaffold( child: Scaffold(
backgroundColor: RetroColors.deepBrown, backgroundColor: RetroColors.deepBrown,
appBar: AppBar( appBar: _buildDesktopAppBar(context, state),
backgroundColor: RetroColors.darkBrown,
title: Text(
L10n.of(context).progressQuestTitle(state.traits.name),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: RetroColors.gold,
),
),
actions: [
// 치트 버튼 (디버그용)
if (widget.controller.cheatsEnabled) ...[
IconButton(
icon: const Text('L+1'),
tooltip: L10n.of(context).levelUp,
onPressed: () =>
widget.controller.loop?.cheatCompleteTask(),
),
IconButton(
icon: const Text('Q!'),
tooltip: L10n.of(context).completeQuest,
onPressed: () =>
widget.controller.loop?.cheatCompleteQuest(),
),
IconButton(
icon: const Text('P!'),
tooltip: L10n.of(context).completePlot,
onPressed: () =>
widget.controller.loop?.cheatCompletePlot(),
),
],
// 통계 버튼
IconButton(
icon: const Icon(Icons.bar_chart),
tooltip: game_l10n.uiStatistics,
onPressed: () => _showStatisticsDialog(context),
),
// 도움말 버튼
IconButton(
icon: const Icon(Icons.help_outline),
tooltip: game_l10n.uiHelp,
onPressed: () => HelpDialog.show(context),
),
// 설정 버튼
IconButton(
icon: const Icon(Icons.settings),
tooltip: game_l10n.uiSettings,
onPressed: () => _showSettingsScreen(context),
),
],
),
body: Stack( body: Stack(
children: [ children: [
// 메인 게임 UI _buildDesktopMainContent(state),
Column( ..._buildOverlays(state),
children: [
// 상단: ASCII 애니메이션 + Task Progress (Phase 7: 고정 4색 팔레트)
TaskProgressPanel(
progress: state.progress,
speedMultiplier:
widget.controller.loop?.speedMultiplier ?? 1,
onSpeedCycle: () {
widget.controller.loop?.cycleSpeed();
setState(() {});
},
// 특수 애니메이션 중에는 일시정지 상태로 표시하지 않음
isPaused:
!widget.controller.isRunning &&
_specialAnimation == null,
onPauseToggle: () async {
await widget.controller.togglePause();
setState(() {});
},
specialAnimation: _specialAnimation,
weaponName: state.equipment.weapon,
shieldName: state.equipment.shield,
characterLevel: state.traits.level,
monsterLevel: state.progress.currentTask.monsterLevel,
monsterGrade: state.progress.currentTask.monsterGrade,
monsterSize: state.progress.currentTask.monsterSize,
latestCombatEvent:
state.progress.currentCombat?.recentEvents.lastOrNull,
raceId: state.traits.raceId,
),
// 메인 3패널 영역
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 좌측 패널: Character Sheet
Expanded(flex: 2, child: _buildCharacterPanel(state)),
// 중앙 패널: Equipment/Inventory
Expanded(flex: 3, child: _buildEquipmentPanel(state)),
// 우측 패널: Plot/Quest
Expanded(flex: 2, child: _buildQuestPanel(state)),
],
),
),
],
),
// 사망 오버레이
if (state.isDead && state.deathInfo != null)
DeathOverlay(
deathInfo: state.deathInfo!,
traits: state.traits,
onResurrect: _handleResurrect,
onAdRevive: _handleAdRevive,
isPaidUser: IAPService.instance.isAdRemovalPurchased,
),
// 승리 오버레이 (게임 클리어)
if (widget.controller.isComplete)
VictoryOverlay(
traits: state.traits,
stats: state.stats,
progress: state.progress,
elapsedMs: state.skillSystem.elapsedMs,
onComplete: _handleVictoryComplete,
),
], ],
), ),
), ),
@@ -847,6 +713,118 @@ class _GamePlayScreenState extends State<GamePlayScreen>
); );
} }
/// 데스크톱 앱바
PreferredSizeWidget _buildDesktopAppBar(BuildContext context, GameState state) {
return AppBar(
backgroundColor: RetroColors.darkBrown,
title: Text(
L10n.of(context).progressQuestTitle(state.traits.name),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: RetroColors.gold,
),
),
actions: [
if (widget.controller.cheatsEnabled) ...[
IconButton(
icon: const Text('L+1'),
tooltip: L10n.of(context).levelUp,
onPressed: () => widget.controller.loop?.cheatCompleteTask(),
),
IconButton(
icon: const Text('Q!'),
tooltip: L10n.of(context).completeQuest,
onPressed: () => widget.controller.loop?.cheatCompleteQuest(),
),
IconButton(
icon: const Text('P!'),
tooltip: L10n.of(context).completePlot,
onPressed: () => widget.controller.loop?.cheatCompletePlot(),
),
],
IconButton(
icon: const Icon(Icons.bar_chart),
tooltip: game_l10n.uiStatistics,
onPressed: () => _showStatisticsDialog(context),
),
IconButton(
icon: const Icon(Icons.help_outline),
tooltip: game_l10n.uiHelp,
onPressed: () => HelpDialog.show(context),
),
IconButton(
icon: const Icon(Icons.settings),
tooltip: game_l10n.uiSettings,
onPressed: () => _showSettingsScreen(context),
),
],
);
}
/// 데스크톱 메인 컨텐츠 (3패널)
Widget _buildDesktopMainContent(GameState state) {
return Column(
children: [
TaskProgressPanel(
progress: state.progress,
speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1,
onSpeedCycle: () {
widget.controller.loop?.cycleSpeed();
setState(() {});
},
isPaused: !widget.controller.isRunning && _specialAnimation == null,
onPauseToggle: () async {
await widget.controller.togglePause();
setState(() {});
},
specialAnimation: _specialAnimation,
weaponName: state.equipment.weapon,
shieldName: state.equipment.shield,
characterLevel: state.traits.level,
monsterLevel: state.progress.currentTask.monsterLevel,
monsterGrade: state.progress.currentTask.monsterGrade,
monsterSize: state.progress.currentTask.monsterSize,
latestCombatEvent:
state.progress.currentCombat?.recentEvents.lastOrNull,
raceId: state.traits.raceId,
),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(flex: 2, child: _buildCharacterPanel(state)),
Expanded(flex: 3, child: _buildEquipmentPanel(state)),
Expanded(flex: 2, child: _buildQuestPanel(state)),
],
),
),
],
);
}
/// 공통 오버레이 (사망, 승리)
List<Widget> _buildOverlays(GameState state) {
return [
if (state.isDead && state.deathInfo != null)
DeathOverlay(
deathInfo: state.deathInfo!,
traits: state.traits,
onResurrect: _handleResurrect,
onAdRevive: _handleAdRevive,
isPaidUser: IAPService.instance.isAdRemovalPurchased,
),
if (widget.controller.isComplete)
VictoryOverlay(
traits: state.traits,
stats: state.stats,
progress: state.progress,
elapsedMs: state.skillSystem.elapsedMs,
onComplete: _handleVictoryComplete,
),
];
}
/// 키보드 단축키 핸들러 (웹/데스크톱) /// 키보드 단축키 핸들러 (웹/데스크톱)
/// Space: 일시정지/재개, S: 속도 변경, H: 도움말, Esc: 설정 /// Space: 일시정지/재개, S: 속도 변경, H: 도움말, Esc: 설정
KeyEventResult _handleKeyboardShortcut(KeyEvent event, BuildContext context) { KeyEventResult _handleKeyboardShortcut(KeyEvent event, BuildContext context) {

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,11 @@ import 'package:asciineverdie/src/features/game/pages/skills_page.dart';
import 'package:asciineverdie/src/features/game/pages/story_page.dart'; import 'package:asciineverdie/src/features/game/pages/story_page.dart';
import 'package:asciineverdie/src/features/game/widgets/carousel_nav_bar.dart'; import 'package:asciineverdie/src/features/game/widgets/carousel_nav_bar.dart';
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart'; import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
import 'package:asciineverdie/src/features/game/widgets/dialogs/retro_confirm_dialog.dart';
import 'package:asciineverdie/src/features/game/widgets/dialogs/retro_select_dialog.dart';
import 'package:asciineverdie/src/features/game/widgets/dialogs/retro_sound_dialog.dart';
import 'package:asciineverdie/src/features/game/widgets/enhanced_animation_panel.dart'; import 'package:asciineverdie/src/features/game/widgets/enhanced_animation_panel.dart';
import 'package:asciineverdie/src/features/game/widgets/menu/retro_menu_widgets.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart'; import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
@@ -177,7 +181,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
void _showLanguageDialog(BuildContext context) { void _showLanguageDialog(BuildContext context) {
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (context) => _RetroSelectDialog( builder: (context) => RetroSelectDialog(
title: l10n.menuLanguage.toUpperCase(), title: l10n.menuLanguage.toUpperCase(),
children: [ children: [
_buildLanguageOption(context, 'en', l10n.languageEnglish, '🇺🇸'), _buildLanguageOption(context, 'en', l10n.languageEnglish, '🇺🇸'),
@@ -195,7 +199,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
String flag, String flag,
) { ) {
final isSelected = l10n.currentGameLocale == locale; final isSelected = l10n.currentGameLocale == locale;
return _RetroOptionItem( return RetroOptionItem(
label: label.toUpperCase(), label: label.toUpperCase(),
prefix: flag, prefix: flag,
isSelected: isSelected, isSelected: isSelected,
@@ -224,7 +228,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (context) => StatefulBuilder( builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => _RetroSoundDialog( builder: (context, setDialogState) => RetroSoundDialog(
bgmVolume: bgmVolume, bgmVolume: bgmVolume,
sfxVolume: sfxVolume, sfxVolume: sfxVolume,
onBgmChanged: (double value) { onBgmChanged: (double value) {
@@ -244,7 +248,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
void _showDeleteConfirmDialog(BuildContext context) { void _showDeleteConfirmDialog(BuildContext context) {
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (context) => _RetroConfirmDialog( builder: (context) => RetroConfirmDialog(
title: l10n.confirmDeleteTitle.toUpperCase(), title: l10n.confirmDeleteTitle.toUpperCase(),
message: l10n.confirmDeleteMessage, message: l10n.confirmDeleteMessage,
confirmText: l10n.buttonConfirm.toUpperCase(), confirmText: l10n.buttonConfirm.toUpperCase(),
@@ -262,14 +266,11 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
Future<void> _showTestCharacterDialog(BuildContext context) async { Future<void> _showTestCharacterDialog(BuildContext context) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => _RetroConfirmDialog( builder: (context) => RetroConfirmDialog(
title: 'CREATE TEST CHARACTER?', title: L10n.of(context).debugCreateTestCharacterTitle,
message: '현재 캐릭터가 레벨 100으로 변환되어\n' message: L10n.of(context).debugCreateTestCharacterMessage,
'명예의 전당에 등록됩니다.\n\n' confirmText: L10n.of(context).createButton,
'⚠️ 현재 세이브 파일이 삭제됩니다.\n' cancelText: L10n.of(context).cancel.toUpperCase(),
'이 작업은 되돌릴 수 없습니다.',
confirmText: 'CREATE',
cancelText: 'CANCEL',
onConfirm: () => Navigator.of(context).pop(true), onConfirm: () => Navigator.of(context).pop(true),
onCancel: () => Navigator.of(context).pop(false), onCancel: () => Navigator.of(context).pop(false),
), ),
@@ -326,7 +327,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
Icon(Icons.settings, color: gold, size: 18), Icon(Icons.settings, color: gold, size: 18),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'OPTIONS', L10n.of(context).optionsTitle,
style: TextStyle( style: TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 14, fontSize: 14,
@@ -350,10 +351,10 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// === 게임 제어 === // === 게임 제어 ===
const _RetroMenuSection(title: 'CONTROL'), RetroMenuSection(title: L10n.of(context).controlSection),
const SizedBox(height: 8), const SizedBox(height: 8),
// 일시정지/재개 // 일시정지/재개
_RetroMenuItem( RetroMenuItem(
icon: widget.isPaused ? Icons.play_arrow : Icons.pause, icon: widget.isPaused ? Icons.play_arrow : Icons.pause,
iconColor: widget.isPaused iconColor: widget.isPaused
? RetroColors.expOf(context) ? RetroColors.expOf(context)
@@ -368,7 +369,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// 속도 조절 // 속도 조절
_RetroMenuItem( RetroMenuItem(
icon: Icons.speed, icon: Icons.speed,
iconColor: gold, iconColor: gold,
label: l10n.menuSpeed.toUpperCase(), label: l10n.menuSpeed.toUpperCase(),
@@ -377,10 +378,10 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
const SizedBox(height: 16), const SizedBox(height: 16),
// === 정보 === // === 정보 ===
const _RetroMenuSection(title: 'INFO'), RetroMenuSection(title: L10n.of(context).infoSection),
const SizedBox(height: 8), const SizedBox(height: 8),
if (widget.onShowStatistics != null) if (widget.onShowStatistics != null)
_RetroMenuItem( RetroMenuItem(
icon: Icons.bar_chart, icon: Icons.bar_chart,
iconColor: RetroColors.mpOf(context), iconColor: RetroColors.mpOf(context),
label: l10n.uiStatistics.toUpperCase(), label: l10n.uiStatistics.toUpperCase(),
@@ -391,7 +392,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
), ),
if (widget.onShowHelp != null) ...[ if (widget.onShowHelp != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
_RetroMenuItem( RetroMenuItem(
icon: Icons.help_outline, icon: Icons.help_outline,
iconColor: RetroColors.expOf(context), iconColor: RetroColors.expOf(context),
label: l10n.uiHelp.toUpperCase(), label: l10n.uiHelp.toUpperCase(),
@@ -404,9 +405,9 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
const SizedBox(height: 16), const SizedBox(height: 16),
// === 설정 === // === 설정 ===
const _RetroMenuSection(title: 'SETTINGS'), RetroMenuSection(title: L10n.of(context).settingsSection),
const SizedBox(height: 8), const SizedBox(height: 8),
_RetroMenuItem( RetroMenuItem(
icon: Icons.language, icon: Icons.language,
iconColor: RetroColors.mpOf(context), iconColor: RetroColors.mpOf(context),
label: l10n.menuLanguage.toUpperCase(), label: l10n.menuLanguage.toUpperCase(),
@@ -419,7 +420,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
if (widget.onBgmVolumeChange != null || if (widget.onBgmVolumeChange != null ||
widget.onSfxVolumeChange != null) ...[ widget.onSfxVolumeChange != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
_RetroMenuItem( RetroMenuItem(
icon: widget.bgmVolume == 0 && widget.sfxVolume == 0 icon: widget.bgmVolume == 0 && widget.sfxVolume == 0
? Icons.volume_off ? Icons.volume_off
: Icons.volume_up, : Icons.volume_up,
@@ -435,9 +436,9 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
const SizedBox(height: 16), const SizedBox(height: 16),
// === 저장/종료 === // === 저장/종료 ===
const _RetroMenuSection(title: 'SAVE / EXIT'), RetroMenuSection(title: L10n.of(context).saveExitSection),
const SizedBox(height: 8), const SizedBox(height: 8),
_RetroMenuItem( RetroMenuItem(
icon: Icons.save, icon: Icons.save,
iconColor: RetroColors.mpOf(context), iconColor: RetroColors.mpOf(context),
label: l10n.menuSave.toUpperCase(), label: l10n.menuSave.toUpperCase(),
@@ -450,7 +451,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
}, },
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_RetroMenuItem( RetroMenuItem(
icon: Icons.refresh, icon: Icons.refresh,
iconColor: RetroColors.warningOf(context), iconColor: RetroColors.warningOf(context),
label: l10n.menuNewGame.toUpperCase(), label: l10n.menuNewGame.toUpperCase(),
@@ -461,7 +462,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
}, },
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_RetroMenuItem( RetroMenuItem(
icon: Icons.exit_to_app, icon: Icons.exit_to_app,
iconColor: RetroColors.hpOf(context), iconColor: RetroColors.hpOf(context),
label: localizations.exitGame.toUpperCase(), label: localizations.exitGame.toUpperCase(),
@@ -474,38 +475,38 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
// === 치트 섹션 (디버그 모드에서만) === // === 치트 섹션 (디버그 모드에서만) ===
if (widget.cheatsEnabled) ...[ if (widget.cheatsEnabled) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
_RetroMenuSection( RetroMenuSection(
title: 'DEBUG CHEATS', title: L10n.of(context).debugCheatsTitle,
color: RetroColors.hpOf(context), color: RetroColors.hpOf(context),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_RetroMenuItem( RetroMenuItem(
icon: Icons.fast_forward, icon: Icons.fast_forward,
iconColor: RetroColors.hpOf(context), iconColor: RetroColors.hpOf(context),
label: 'SKIP TASK (L+1)', label: L10n.of(context).debugSkipTask,
subtitle: '태스크 즉시 완료', subtitle: L10n.of(context).debugSkipTaskDesc,
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
widget.onCheatTask?.call(); widget.onCheatTask?.call();
}, },
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_RetroMenuItem( RetroMenuItem(
icon: Icons.skip_next, icon: Icons.skip_next,
iconColor: RetroColors.hpOf(context), iconColor: RetroColors.hpOf(context),
label: 'SKIP QUEST (Q!)', label: L10n.of(context).debugSkipQuest,
subtitle: '퀘스트 즉시 완료', subtitle: L10n.of(context).debugSkipQuestDesc,
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
widget.onCheatQuest?.call(); widget.onCheatQuest?.call();
}, },
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_RetroMenuItem( RetroMenuItem(
icon: Icons.double_arrow, icon: Icons.double_arrow,
iconColor: RetroColors.hpOf(context), iconColor: RetroColors.hpOf(context),
label: 'SKIP ACT (P!)', label: L10n.of(context).debugSkipAct,
subtitle: '액트 즉시 완료', subtitle: L10n.of(context).debugSkipActDesc,
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
widget.onCheatPlot?.call(); widget.onCheatPlot?.call();
@@ -516,16 +517,16 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
// === 디버그 도구 섹션 === // === 디버그 도구 섹션 ===
if (kDebugMode && widget.onCreateTestCharacter != null) ...[ if (kDebugMode && widget.onCreateTestCharacter != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
_RetroMenuSection( RetroMenuSection(
title: 'DEBUG TOOLS', title: L10n.of(context).debugToolsTitle,
color: RetroColors.warningOf(context), color: RetroColors.warningOf(context),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_RetroMenuItem( RetroMenuItem(
icon: Icons.science, icon: Icons.science,
iconColor: RetroColors.warningOf(context), iconColor: RetroColors.warningOf(context),
label: 'CREATE TEST CHARACTER', label: L10n.of(context).debugCreateTestCharacter,
subtitle: '레벨 100 캐릭터를 명예의 전당에 등록', subtitle: L10n.of(context).debugCreateTestCharacterDesc,
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
_showTestCharacterDialog(context); _showTestCharacterDialog(context);
@@ -553,7 +554,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
final isSpeedBoostActive = widget.isSpeedBoostActive; final isSpeedBoostActive = widget.isSpeedBoostActive;
final adSpeed = widget.adSpeedMultiplier; final adSpeed = widget.adSpeedMultiplier;
return _RetroSpeedChip( return RetroSpeedChip(
speed: adSpeed, speed: adSpeed,
isSelected: isSpeedBoostActive, isSelected: isSpeedBoostActive,
isAdBased: !isSpeedBoostActive && !widget.isPaidUser, isAdBased: !isSpeedBoostActive && !widget.isPaidUser,
@@ -685,539 +686,4 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
), ),
); );
} }
}
// ═══════════════════════════════════════════════════════════════════════════
// 레트로 스타일 옵션 메뉴 위젯들
// ═══════════════════════════════════════════════════════════════════════════
/// 메뉴 섹션 타이틀
class _RetroMenuSection extends StatelessWidget {
const _RetroMenuSection({required this.title, this.color});
final String title;
final Color? color;
@override
Widget build(BuildContext context) {
final gold = color ?? RetroColors.goldOf(context);
return Row(
children: [
Container(width: 4, height: 14, color: gold),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 9,
color: gold,
letterSpacing: 1,
),
),
],
);
}
}
/// 메뉴 아이템
class _RetroMenuItem extends StatelessWidget {
const _RetroMenuItem({
required this.icon,
required this.iconColor,
required this.label,
this.value,
this.subtitle,
this.trailing,
this.onTap,
});
final IconData icon;
final Color iconColor;
final String label;
final String? value;
final String? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final border = RetroColors.borderOf(context);
final panelBg = RetroColors.panelBgOf(context);
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: panelBg,
border: Border.all(color: border, width: 1),
),
child: Row(
children: [
Icon(icon, size: 16, color: iconColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: RetroColors.textPrimaryOf(context),
),
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: RetroColors.textMutedOf(context),
),
),
],
],
),
),
if (value != null)
Text(
value!,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.goldOf(context),
),
),
if (trailing != null) trailing!,
],
),
),
);
}
}
/// 속도 선택 칩
class _RetroSpeedChip extends StatelessWidget {
const _RetroSpeedChip({
required this.speed,
required this.isSelected,
required this.onTap,
this.isAdBased = false,
this.isDisabled = false,
});
final int speed;
final bool isSelected;
final VoidCallback onTap;
final bool isAdBased;
/// 비활성 상태 (반투명, 탭 무시)
final bool isDisabled;
@override
Widget build(BuildContext context) {
final gold = RetroColors.goldOf(context);
final warning = RetroColors.warningOf(context);
final border = RetroColors.borderOf(context);
// 비활성 상태면 반투명 처리
final opacity = isDisabled ? 0.4 : 1.0;
final Color bgColor;
final Color textColor;
final Color borderColor;
if (isSelected) {
bgColor = isAdBased
? warning.withValues(alpha: 0.3 * opacity)
: gold.withValues(alpha: 0.3 * opacity);
textColor = (isAdBased ? warning : gold).withValues(alpha: opacity);
borderColor = (isAdBased ? warning : gold).withValues(alpha: opacity);
} else if (isAdBased) {
bgColor = Colors.transparent;
textColor = warning.withValues(alpha: opacity);
borderColor = warning.withValues(alpha: opacity);
} else {
bgColor = Colors.transparent;
textColor = RetroColors.textMutedOf(context).withValues(alpha: opacity);
borderColor = border.withValues(alpha: opacity);
}
return GestureDetector(
// 비활성 상태면 탭 무시
onTap: isDisabled ? null : onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: bgColor,
border: Border.all(color: borderColor, width: isSelected ? 2 : 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isAdBased && !isSelected && !isDisabled)
Padding(
padding: const EdgeInsets.only(right: 2),
child: Text(
'',
style: TextStyle(fontSize: 7, color: warning),
),
),
Text(
'${speed}x',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 8,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: textColor,
),
),
],
),
),
);
}
}
/// 선택 다이얼로그
class _RetroSelectDialog extends StatelessWidget {
const _RetroSelectDialog({required this.title, required this.children});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final background = RetroColors.backgroundOf(context);
final gold = RetroColors.goldOf(context);
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
constraints: const BoxConstraints(maxWidth: 320),
decoration: BoxDecoration(
color: background,
border: Border.all(color: gold, width: 2),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 타이틀
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: gold.withValues(alpha: 0.2),
child: Text(
title,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: gold,
),
textAlign: TextAlign.center,
),
),
// 옵션 목록
Padding(
padding: const EdgeInsets.all(12),
child: Column(children: children),
),
],
),
),
);
}
}
/// 선택 옵션 아이템
class _RetroOptionItem extends StatelessWidget {
const _RetroOptionItem({
required this.label,
required this.isSelected,
required this.onTap,
this.prefix,
});
final String label;
final bool isSelected;
final VoidCallback onTap;
final String? prefix;
@override
Widget build(BuildContext context) {
final gold = RetroColors.goldOf(context);
final border = RetroColors.borderOf(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: isSelected ? gold.withValues(alpha: 0.15) : Colors.transparent,
border: Border.all(
color: isSelected ? gold : border,
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
if (prefix != null) ...[
Text(prefix!, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 12),
],
Expanded(
child: Text(
label,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 18,
color: isSelected ? gold : RetroColors.textPrimaryOf(context),
),
),
),
if (isSelected) Icon(Icons.check, size: 16, color: gold),
],
),
),
),
);
}
}
/// 사운드 설정 다이얼로그
class _RetroSoundDialog extends StatelessWidget {
const _RetroSoundDialog({
required this.bgmVolume,
required this.sfxVolume,
required this.onBgmChanged,
required this.onSfxChanged,
});
final double bgmVolume;
final double sfxVolume;
final ValueChanged<double> onBgmChanged;
final ValueChanged<double> onSfxChanged;
@override
Widget build(BuildContext context) {
final background = RetroColors.backgroundOf(context);
final gold = RetroColors.goldOf(context);
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
constraints: const BoxConstraints(maxWidth: 360),
decoration: BoxDecoration(
color: background,
border: Border.all(color: gold, width: 2),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 타이틀
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: gold.withValues(alpha: 0.2),
child: Text(
'SOUND',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: gold,
),
textAlign: TextAlign.center,
),
),
// 슬라이더
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildVolumeSlider(
context,
icon: bgmVolume == 0 ? Icons.music_off : Icons.music_note,
label: 'BGM',
value: bgmVolume,
onChanged: onBgmChanged,
),
const SizedBox(height: 16),
_buildVolumeSlider(
context,
icon: sfxVolume == 0 ? Icons.volume_off : Icons.volume_up,
label: 'SFX',
value: sfxVolume,
onChanged: onSfxChanged,
),
],
),
),
// 확인 버튼
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: SizedBox(
width: double.infinity,
child: RetroTextButton(
text: 'OK',
onPressed: () => Navigator.pop(context),
),
),
),
],
),
),
);
}
Widget _buildVolumeSlider(
BuildContext context, {
required IconData icon,
required String label,
required double value,
required ValueChanged<double> onChanged,
}) {
final gold = RetroColors.goldOf(context);
final border = RetroColors.borderOf(context);
final percentage = (value * 100).round();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: gold),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: RetroColors.textPrimaryOf(context),
),
),
const Spacer(),
Text(
'$percentage%',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: gold,
),
),
],
),
const SizedBox(height: 8),
SliderTheme(
data: SliderThemeData(
trackHeight: 8,
activeTrackColor: gold,
inactiveTrackColor: border,
thumbColor: RetroColors.goldLightOf(context),
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
overlayColor: gold.withValues(alpha: 0.2),
trackShape: const RectangularSliderTrackShape(),
),
child: Slider(value: value, onChanged: onChanged, divisions: 10),
),
],
);
}
}
/// 확인 다이얼로그
class _RetroConfirmDialog extends StatelessWidget {
const _RetroConfirmDialog({
required this.title,
required this.message,
required this.confirmText,
required this.cancelText,
required this.onConfirm,
required this.onCancel,
});
final String title;
final String message;
final String confirmText;
final String cancelText;
final VoidCallback onConfirm;
final VoidCallback onCancel;
@override
Widget build(BuildContext context) {
final background = RetroColors.backgroundOf(context);
final gold = RetroColors.goldOf(context);
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
constraints: const BoxConstraints(maxWidth: 360),
decoration: BoxDecoration(
color: background,
border: Border.all(color: gold, width: 3),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 타이틀
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: gold.withValues(alpha: 0.2),
child: Text(
title,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: gold,
),
textAlign: TextAlign.center,
),
),
// 메시지
Padding(
padding: const EdgeInsets.all(16),
child: Text(
message,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: RetroColors.textPrimaryOf(context),
height: 1.8,
),
textAlign: TextAlign.center,
),
),
// 버튼
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Row(
children: [
Expanded(
child: RetroTextButton(
text: cancelText,
isPrimary: false,
onPressed: onCancel,
),
),
const SizedBox(width: 8),
Expanded(
child: RetroTextButton(
text: confirmText,
onPressed: onConfirm,
),
),
],
),
),
],
),
),
);
}
} }

View File

@@ -0,0 +1,151 @@
import 'dart:async';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/game_statistics.dart';
import 'package:asciineverdie/src/core/storage/statistics_storage.dart';
/// 게임 통계 추적 및 관리를 담당하는 매니저
///
/// 세션 통계와 누적 통계를 관리하고, 게임 상태 변화에 따라
/// 통계를 자동 업데이트합니다.
class GameStatisticsManager {
GameStatisticsManager({
StatisticsStorage? statisticsStorage,
}) : _statisticsStorage = statisticsStorage ?? StatisticsStorage();
final StatisticsStorage _statisticsStorage;
// 현재 세션 통계
SessionStatistics _sessionStats = SessionStatistics.empty();
// 누적 통계
CumulativeStatistics _cumulativeStats = CumulativeStatistics.empty();
// 이전 값 (변화 감지용)
int _previousLevel = 0;
int _previousGold = 0;
int _previousMonstersKilled = 0;
int _previousQuestsCompleted = 0;
/// 현재 세션 통계
SessionStatistics get sessionStats => _sessionStats;
/// 누적 통계
CumulativeStatistics get cumulativeStats => _cumulativeStats;
/// 새 게임 시작 시 통계 초기화
Future<void> initializeForNewGame() async {
_sessionStats = SessionStatistics.empty();
await _statisticsStorage.recordGameStart();
}
/// 게임 로드 시 통계 복원
void restoreFromLoadedGame(GameState state) {
_sessionStats = _sessionStats.copyWith(
deathCount: state.progress.deathCount,
questsCompleted: state.progress.questCount,
monstersKilled: state.progress.monstersKilled,
playTimeMs: state.skillSystem.elapsedMs,
);
}
/// 이전 값 초기화 (통계 변화 추적용)
void initPreviousValues(GameState state) {
_previousLevel = state.traits.level;
_previousGold = state.inventory.gold;
_previousMonstersKilled = state.progress.monstersKilled;
_previousQuestsCompleted = state.progress.questCount;
}
/// 상태 변화에 따른 통계 업데이트
///
/// 매 틱마다 호출되어 레벨업, 골드, 몬스터 처치, 퀘스트 완료 등을 추적
void updateStatistics(GameState next) {
// 플레이 시간 업데이트
_sessionStats = _sessionStats.updatePlayTime(next.skillSystem.elapsedMs);
// 레벨업 감지
_detectLevelUps(next);
// 골드 변화 감지
_detectGoldChanges(next);
// 몬스터 처치 감지
_detectMonsterKills(next);
// 퀘스트 완료 감지
_detectQuestCompletions(next);
}
/// 레벨업 감지 및 기록
void _detectLevelUps(GameState next) {
if (next.traits.level > _previousLevel) {
final levelUps = next.traits.level - _previousLevel;
for (var i = 0; i < levelUps; i++) {
_sessionStats = _sessionStats.recordLevelUp();
}
_previousLevel = next.traits.level;
// 최고 레벨 업데이트
unawaited(_statisticsStorage.updateHighestLevel(next.traits.level));
}
}
/// 골드 변화 감지 및 기록
void _detectGoldChanges(GameState next) {
if (next.inventory.gold > _previousGold) {
final earned = next.inventory.gold - _previousGold;
_sessionStats = _sessionStats.recordGoldEarned(earned);
// 최대 골드 업데이트
unawaited(_statisticsStorage.updateHighestGold(next.inventory.gold));
} else if (next.inventory.gold < _previousGold) {
final spent = _previousGold - next.inventory.gold;
_sessionStats = _sessionStats.recordGoldSpent(spent);
}
_previousGold = next.inventory.gold;
}
/// 몬스터 처치 감지 및 기록
void _detectMonsterKills(GameState next) {
if (next.progress.monstersKilled > _previousMonstersKilled) {
final kills = next.progress.monstersKilled - _previousMonstersKilled;
for (var i = 0; i < kills; i++) {
_sessionStats = _sessionStats.recordKill();
}
_previousMonstersKilled = next.progress.monstersKilled;
}
}
/// 퀘스트 완료 감지 및 기록
void _detectQuestCompletions(GameState next) {
if (next.progress.questCount > _previousQuestsCompleted) {
final quests = next.progress.questCount - _previousQuestsCompleted;
for (var i = 0; i < quests; i++) {
_sessionStats = _sessionStats.recordQuestComplete();
}
_previousQuestsCompleted = next.progress.questCount;
}
}
/// 사망 기록
void recordDeath() {
_sessionStats = _sessionStats.recordDeath();
}
/// 게임 클리어 기록
Future<void> recordGameComplete() async {
await _statisticsStorage.recordGameComplete();
}
/// 누적 통계 로드
Future<void> loadCumulativeStats() async {
_cumulativeStats = await _statisticsStorage.loadCumulative();
}
/// 세션 통계를 누적 통계에 병합
Future<void> mergeSessionStats() async {
await _statisticsStorage.mergeSession(_sessionStats);
_cumulativeStats = await _statisticsStorage.loadCumulative();
}
}

View File

@@ -0,0 +1,135 @@
import 'package:asciineverdie/src/core/engine/test_character_service.dart';
import 'package:asciineverdie/src/core/model/combat_stats.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart';
import 'package:asciineverdie/src/core/storage/save_manager.dart';
import 'package:asciineverdie/src/features/game/managers/game_statistics_manager.dart';
import 'package:flutter/foundation.dart';
/// 명예의 전당 관리를 담당하는 매니저
///
/// 게임 클리어 시 캐릭터 등록, 테스트 캐릭터 생성 등을 담당합니다.
class HallOfFameManager {
HallOfFameManager({
HallOfFameStorage? hallOfFameStorage,
}) : _hallOfFameStorage = hallOfFameStorage ?? HallOfFameStorage();
final HallOfFameStorage _hallOfFameStorage;
/// 명예의 전당 데이터 로드
Future<HallOfFame> load() async {
return _hallOfFameStorage.load();
}
/// 명예의 전당에 캐릭터가 있는지 확인
Future<bool> hasEntries() async {
final hallOfFame = await _hallOfFameStorage.load();
return hallOfFame.entries.isNotEmpty;
}
/// 가용 배속 목록 반환
///
/// - 기본: [1] (1x만)
/// - 명예의 전당에 캐릭터 있으면: [1, 2] (2x 해금)
Future<List<int>> getAvailableSpeeds() async {
final hasCharacters = await hasEntries();
if (hasCharacters) {
return [1, 2]; // 명예의 전당 캐릭터 있으면 2x 해금
}
return [1]; // 기본: 1x만
}
/// 명예의 전당 등록
///
/// 게임 클리어 시 호출되어 캐릭터 정보를 명예의 전당에 등록합니다.
/// 등록 성공 시 세이브 파일을 삭제합니다.
///
/// Returns: 등록 성공 여부
Future<bool> registerCharacter({
required GameState state,
required SaveManager saveManager,
required GameStatisticsManager statisticsManager,
}) async {
try {
debugPrint('[HallOfFame] Starting registration...');
// 최종 전투 스탯 계산
final combatStats = CombatStats.fromStats(
stats: state.stats,
equipment: state.equipment,
level: state.traits.level,
);
final entry = HallOfFameEntry.fromGameState(
state: state,
totalDeaths: state.progress.deathCount,
monstersKilled: state.progress.monstersKilled,
combatStats: combatStats,
);
debugPrint(
'[HallOfFame] Entry created: ${entry.characterName} Lv.${entry.level}',
);
final success = await _hallOfFameStorage.addEntry(entry);
debugPrint('[HallOfFame] Storage save result: $success');
// 통계 기록
await statisticsManager.recordGameComplete();
debugPrint('[HallOfFame] Registration complete');
// 클리어된 세이브 파일 삭제 (중복 등록 방지)
if (success) {
final deleteResult = await saveManager.deleteSave();
debugPrint('[HallOfFame] Save file deleted: ${deleteResult.success}');
}
return success;
} catch (e, st) {
debugPrint('[HallOfFame] ERROR: $e');
debugPrint('[HallOfFame] StackTrace: $st');
return false;
}
}
/// 테스트 캐릭터 생성 (디버그 모드 전용)
///
/// 현재 캐릭터를 레벨 100, 고급 장비, 다수의 스킬을 가진
/// 캐릭터로 변환하여 명예의 전당에 등록하고 세이브를 삭제합니다.
///
/// Returns: 등록 성공 여부
Future<bool> createTestCharacter({
required GameState state,
required SaveManager saveManager,
}) async {
try {
debugPrint('[TestCharacter] Creating test character...');
// TestCharacterService로 테스트 캐릭터 생성
final testService = TestCharacterService(rng: state.rng);
final entry = testService.createTestCharacter(state);
debugPrint(
'[TestCharacter] Entry created: ${entry.characterName} Lv.${entry.level}',
);
// 명예의 전당에 등록
final success = await _hallOfFameStorage.addEntry(entry);
debugPrint('[TestCharacter] HallOfFame save result: $success');
if (success) {
// 세이브 파일 삭제
final deleteResult = await saveManager.deleteSave();
debugPrint('[TestCharacter] Save deleted: ${deleteResult.success}');
}
debugPrint('[TestCharacter] Complete');
return success;
} catch (e, st) {
debugPrint('[TestCharacter] ERROR: $e');
debugPrint('[TestCharacter] StackTrace: $st');
return false;
}
}
}

View File

@@ -0,0 +1,156 @@
import 'package:asciineverdie/src/core/engine/ad_service.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart';
import 'package:asciineverdie/src/core/engine/resurrection_service.dart';
import 'package:asciineverdie/src/core/engine/shop_service.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/monetization_state.dart';
import 'package:asciineverdie/src/core/storage/save_manager.dart';
import 'package:flutter/foundation.dart';
/// 부활 결과
class ResurrectionResult {
const ResurrectionResult({
required this.state,
required this.monetization,
required this.success,
});
final GameState state;
final MonetizationState monetization;
final bool success;
}
/// 사망 및 부활 처리를 담당하는 매니저
///
/// 일반 부활, 광고 부활, 자동 부활 등 모든 부활 관련 로직을 담당합니다.
class ResurrectionManager {
ResurrectionManager();
/// 자동 부활 활성화 여부
bool autoResurrect = false;
/// 자동 부활 조건 확인
///
/// 다음 조건 중 하나라도 만족하면 자동 부활:
/// 1. 수동 토글 자동부활 (autoResurrect)
/// 2. 유료 유저 (IAP 광고 제거 구매)
/// 3. 광고 부활 버프 활성 (10분간)
bool shouldAutoResurrect({
required MonetizationState monetization,
required int elapsedMs,
}) {
return autoResurrect ||
IAPService.instance.isAdRemovalPurchased ||
monetization.isAutoReviveActive(elapsedMs);
}
/// 일반 부활 처리 (HP 50% 회복)
///
/// Returns: 부활된 GameState (부활 불가 시 null)
Future<GameState?> processResurrection({
required GameState state,
required SaveManager saveManager,
required bool cheatsEnabled,
required MonetizationState monetization,
}) async {
if (!state.isDead) return null;
final shopService = ShopService(rng: state.rng);
final resurrectionService = ResurrectionService(shopService: shopService);
final resurrectedState = resurrectionService.processResurrection(state);
// 저장
await saveManager.saveState(
resurrectedState,
cheatsEnabled: cheatsEnabled,
monetization: monetization,
);
debugPrint('[Resurrection] Normal resurrection complete');
return resurrectedState;
}
/// 광고 부활 처리 (HP 100% + 아이템 복구 + 10분 자동부활 버프)
///
/// 유료 유저: 광고 없이 부활
/// 무료 유저: 리워드 광고 시청 후 부활
///
/// Returns: ResurrectionResult (부활 불가/실패 시 success=false)
Future<ResurrectionResult> processAdRevive({
required GameState state,
required SaveManager saveManager,
required bool cheatsEnabled,
required MonetizationState monetization,
}) async {
if (!state.isDead) {
return ResurrectionResult(
state: state,
monetization: monetization,
success: false,
);
}
final shopService = ShopService(rng: state.rng);
final resurrectionService = ResurrectionService(shopService: shopService);
// 부활 처리 결과 저장용
GameState? revivedState;
MonetizationState updatedMonetization = monetization;
void processRevive() {
revivedState = resurrectionService.processAdRevive(state);
// 10분 자동부활 버프 활성화 (elapsedMs 기준)
final buffEndMs =
revivedState!.skillSystem.elapsedMs + 600000; // 10분 = 600,000ms
updatedMonetization = monetization.copyWith(autoReviveEndMs: buffEndMs);
debugPrint(
'[Resurrection] Ad revive complete, auto-revive buff until $buffEndMs ms');
}
// 유료 유저는 광고 없이 부활
if (IAPService.instance.isAdRemovalPurchased) {
processRevive();
await saveManager.saveState(
revivedState!,
cheatsEnabled: cheatsEnabled,
monetization: updatedMonetization,
);
debugPrint('[Resurrection] Ad revive (paid user)');
return ResurrectionResult(
state: revivedState!,
monetization: updatedMonetization,
success: true,
);
}
// 무료 유저는 리워드 광고 필요
final adResult = await AdService.instance.showRewardedAd(
adType: AdType.rewardRevive,
onRewarded: processRevive,
);
if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) {
await saveManager.saveState(
revivedState!,
cheatsEnabled: cheatsEnabled,
monetization: updatedMonetization,
);
debugPrint('[Resurrection] Ad revive (free user with ad)');
return ResurrectionResult(
state: revivedState!,
monetization: updatedMonetization,
success: true,
);
}
debugPrint('[Resurrection] Ad revive failed: $adResult');
return ResurrectionResult(
state: state,
monetization: monetization,
success: false,
);
}
}

View File

@@ -0,0 +1,196 @@
import 'dart:async';
import 'package:asciineverdie/src/core/engine/debug_settings_service.dart';
import 'package:asciineverdie/src/core/engine/progress_loop.dart';
import 'package:asciineverdie/src/core/engine/return_rewards_service.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/monetization_state.dart';
import 'package:asciineverdie/src/core/model/treasure_chest.dart';
import 'package:asciineverdie/src/core/storage/save_manager.dart';
import 'package:flutter/foundation.dart';
/// 복귀 보상 기능 관리자 (Phase 7)
///
/// 장시간 접속하지 않은 유저에게 복귀 보상(상자)을 지급하는
/// 기능을 담당합니다.
class ReturnRewardsManager {
ReturnRewardsManager();
// 대기 중인 복귀 보상
ReturnChestReward? _pendingReturnReward;
/// 복귀 보상 콜백 (UI에서 다이얼로그 표시용)
void Function(ReturnChestReward reward)? onReturnRewardAvailable;
/// 대기 중인 복귀 보상
ReturnChestReward? get pendingReturnReward => _pendingReturnReward;
/// 복귀 보상 체크 (로드 시 호출)
///
/// 오프라인 시간에 따라 보상을 계산하고, 보상이 있으면
/// UI에 알림을 예약합니다.
void checkReturnRewards({
required MonetizationState monetization,
required GameState loaded,
}) {
final rewardsService = ReturnRewardsService.instance;
final debugSettings = DebugSettingsService.instance;
// 디버그 모드: 오프라인 시간 시뮬레이션 적용
final lastPlayTime = debugSettings.getSimulatedLastPlayTime(
monetization.lastPlayTime,
);
final reward = rewardsService.calculateReward(
lastPlayTime: lastPlayTime,
currentTime: DateTime.now(),
isPaidUser: monetization.isPaidUser,
);
if (reward.hasReward) {
_pendingReturnReward = reward;
debugPrint('[ReturnRewards] Reward available: ${reward.chestCount} chests, '
'${reward.hoursAway} hours away');
// UI에서 다이얼로그 표시를 위해 콜백 호출
// startNew 후에 호출하도록 딜레이
Future<void>.delayed(const Duration(milliseconds: 500), () {
if (_pendingReturnReward != null) {
onReturnRewardAvailable?.call(_pendingReturnReward!);
}
});
}
}
/// 복귀 보상 수령 완료 (상자 보상 적용)
///
/// [rewards] 오픈된 상자 보상 목록
/// Returns: 보상이 적용된 새로운 GameState
Future<GameState?> applyReturnReward({
required List<ChestReward> rewards,
required GameState state,
required ProgressLoop? loop,
required SaveManager saveManager,
required bool cheatsEnabled,
required MonetizationState monetization,
}) async {
if (rewards.isEmpty) {
// 보상 없이 건너뛴 경우
_pendingReturnReward = null;
debugPrint('[ReturnRewards] Reward skipped');
return null;
}
var updatedState = state;
// 보상 적용
for (final reward in rewards) {
updatedState = _applySingleReward(updatedState, reward);
}
loop?.replaceState(updatedState); // ProgressLoop 상태도 업데이트
// 저장
unawaited(saveManager.saveState(
updatedState,
cheatsEnabled: cheatsEnabled,
monetization: monetization,
));
_pendingReturnReward = null;
debugPrint('[ReturnRewards] Rewards applied: ${rewards.length} items');
return updatedState;
}
/// 단일 보상 적용 (내부)
GameState _applySingleReward(GameState state, ChestReward reward) {
switch (reward.type) {
case ChestRewardType.equipment:
return _applyEquipmentReward(state, reward);
case ChestRewardType.potion:
return _applyPotionReward(state, reward);
case ChestRewardType.gold:
return _applyGoldReward(state, reward);
case ChestRewardType.experience:
return _applyExperienceReward(state, reward);
}
}
/// 장비 보상 적용
GameState _applyEquipmentReward(GameState state, ChestReward reward) {
if (reward.equipment == null) return state;
// 현재 장비와 비교하여 더 좋으면 자동 장착
final slotIndex = reward.equipment!.slot.index;
final currentItem = state.equipment.getItemByIndex(slotIndex);
if (currentItem.isEmpty ||
reward.equipment!.itemWeight > currentItem.itemWeight) {
debugPrint('[ReturnRewards] Equipped: ${reward.equipment!.name}');
return state.copyWith(
equipment: state.equipment.setItemByIndex(
slotIndex,
reward.equipment!,
),
);
}
// 더 좋지 않으면 판매 (골드로 변환)
final sellPrice =
(reward.equipment!.level * 50 * 0.3).round().clamp(1, 99999);
debugPrint('[ReturnRewards] Sold: ${reward.equipment!.name} '
'for $sellPrice gold');
return state.copyWith(
inventory: state.inventory.copyWith(
gold: state.inventory.gold + sellPrice,
),
);
}
/// 포션 보상 적용
GameState _applyPotionReward(GameState state, ChestReward reward) {
if (reward.potionId == null) return state;
debugPrint('[ReturnRewards] Added potion: ${reward.potionId} '
'x${reward.potionCount}');
return state.copyWith(
potionInventory: state.potionInventory.addPotion(
reward.potionId!,
reward.potionCount ?? 1,
),
);
}
/// 골드 보상 적용
GameState _applyGoldReward(GameState state, ChestReward reward) {
if (reward.gold == null || reward.gold! <= 0) return state;
debugPrint('[ReturnRewards] Added gold: ${reward.gold}');
return state.copyWith(
inventory: state.inventory.copyWith(
gold: state.inventory.gold + reward.gold!,
),
);
}
/// 경험치 보상 적용
GameState _applyExperienceReward(GameState state, ChestReward reward) {
if (reward.experience == null || reward.experience! <= 0) return state;
debugPrint('[ReturnRewards] Added experience: ${reward.experience}');
return state.copyWith(
progress: state.progress.copyWith(
exp: state.progress.exp.copyWith(
position: state.progress.exp.position + reward.experience!,
),
),
);
}
/// 복귀 보상 건너뛰기
void skipReturnReward() {
_pendingReturnReward = null;
debugPrint('[ReturnRewards] Reward skipped by user');
}
}

View File

@@ -0,0 +1,211 @@
import 'package:asciineverdie/src/core/engine/ad_service.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart';
import 'package:asciineverdie/src/core/engine/progress_loop.dart';
import 'package:asciineverdie/src/core/model/monetization_state.dart';
import 'package:flutter/foundation.dart';
/// 속도 부스트(광고 배속) 기능 관리자
///
/// 광고 시청 후 일정 시간 동안 게임 속도를 높이는 기능을 담당합니다.
/// 게임 시간(elapsedMs) 기준으로 종료 시점을 판정합니다.
class SpeedBoostManager {
SpeedBoostManager({
required bool Function() cheatsEnabledGetter,
required Future<List<int>> Function() getAvailableSpeeds,
}) : _cheatsEnabledGetter = cheatsEnabledGetter,
_getAvailableSpeeds = getAvailableSpeeds;
final bool Function() _cheatsEnabledGetter;
final Future<List<int>> Function() _getAvailableSpeeds;
// 속도 부스트 상태
bool _isSpeedBoostActive = false;
static const int _speedBoostDuration = 300; // 5분 (게임 시간 기준, 초)
// 광고 표시 중 플래그 (lifecycle reload 방지용)
bool _isShowingAd = false;
int _adEndTimeMs = 0; // 광고 종료 시점 (밀리초)
/// 배속 저장 (pause/resume 시 유지)
int savedSpeedMultiplier = 1;
/// 상태 변경 알림 콜백
VoidCallback? onStateChanged;
/// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x)
int get speedBoostMultiplier =>
(kDebugMode && _cheatsEnabledGetter()) ? 20 : 5;
/// 속도 부스트 활성화 여부
bool get isSpeedBoostActive => _isSpeedBoostActive;
/// 광고 표시 중 여부 (lifecycle reload 방지용)
bool get isShowingAd => _isShowingAd;
/// 최근 광고를 시청했는지 여부 (1초 이내)
bool get isRecentlyShowedAd {
if (_adEndTimeMs == 0) return false;
return DateTime.now().millisecondsSinceEpoch - _adEndTimeMs < 1000;
}
/// 속도 부스트 지속 시간 (초)
int get speedBoostDuration => _speedBoostDuration;
/// 속도 부스트 남은 시간 (초) - 게임 시간(elapsedMs) 기준 계산
int getRemainingSeconds(MonetizationState monetization, int currentElapsedMs) {
if (!_isSpeedBoostActive) return 0;
final endMs = monetization.speedBoostEndMs;
if (endMs == null) return 0;
final remainingMs = endMs - currentElapsedMs;
return remainingMs > 0 ? (remainingMs / 1000).ceil() : 0;
}
/// 현재 실제 배속 (부스트 적용 포함)
int getCurrentSpeedMultiplier(ProgressLoop? loop) {
if (_isSpeedBoostActive) return speedBoostMultiplier;
return loop?.speedMultiplier ?? savedSpeedMultiplier;
}
/// 속도 부스트 활성화 (광고 시청 후)
///
/// 유료 유저: 무료 활성화
/// 무료 유저: 인터스티셜 광고 시청 후 활성화
/// Returns: (활성화 성공 여부, 업데이트된 monetization)
Future<(bool, MonetizationState)> activateSpeedBoost({
required ProgressLoop? loop,
required MonetizationState monetization,
required int currentElapsedMs,
}) async {
if (_isSpeedBoostActive) return (false, monetization);
if (loop == null) return (false, monetization);
// 유료 유저는 무료 활성화
if (IAPService.instance.isAdRemovalPurchased) {
final updatedMonetization = _startSpeedBoost(
loop: loop,
monetization: monetization,
currentElapsedMs: currentElapsedMs,
);
debugPrint('[SpeedBoost] Activated (paid user)');
return (true, updatedMonetization);
}
// 무료 유저는 인터스티셜 광고 필요
MonetizationState updatedMonetization = monetization;
bool activated = false;
_isShowingAd = true;
final adResult = await AdService.instance.showInterstitialAd(
adType: AdType.interstitialSpeed,
onComplete: () {
updatedMonetization = _startSpeedBoost(
loop: loop,
monetization: monetization,
currentElapsedMs: currentElapsedMs,
);
activated = true;
},
);
_isShowingAd = false;
_adEndTimeMs = DateTime.now().millisecondsSinceEpoch;
if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) {
debugPrint('[SpeedBoost] Activated (free user with ad)');
return (activated, updatedMonetization);
}
debugPrint('[SpeedBoost] Activation failed: $adResult');
return (false, monetization);
}
/// 속도 부스트 시작 (내부)
MonetizationState _startSpeedBoost({
required ProgressLoop? loop,
required MonetizationState monetization,
required int currentElapsedMs,
}) {
_isSpeedBoostActive = true;
// loop가 있으면 현재 배속 저장 및 즉시 적용
if (loop != null) {
savedSpeedMultiplier = loop.speedMultiplier;
loop.updateAvailableSpeeds([speedBoostMultiplier]);
}
// 종료 시점 저장 (게임 시간 기준)
final endMs = currentElapsedMs + (_speedBoostDuration * 1000);
final updatedMonetization = monetization.copyWith(speedBoostEndMs: endMs);
debugPrint('[SpeedBoost] Started, ends at $endMs ms');
onStateChanged?.call();
return updatedMonetization;
}
/// 매 틱마다 부스트 만료 체크
///
/// Returns: 부스트가 종료되었으면 true
bool checkExpiry({
required int elapsedMs,
required MonetizationState monetization,
required ProgressLoop? loop,
}) {
if (!_isSpeedBoostActive) return false;
final endMs = monetization.speedBoostEndMs;
if (endMs != null && elapsedMs >= endMs) {
endSpeedBoost(loop: loop);
return true;
}
return false;
}
/// 속도 부스트 종료 (외부 호출 가능)
void endSpeedBoost({required ProgressLoop? loop}) {
_isSpeedBoostActive = false;
// 원래 배속 복원
if (loop != null) {
final savedSpeed = savedSpeedMultiplier;
_getAvailableSpeeds().then((speeds) {
loop.updateAvailableSpeeds(speeds);
loop.setSpeed(savedSpeed);
debugPrint('[SpeedBoost] Speed restored to ${savedSpeed}x');
});
}
onStateChanged?.call();
debugPrint('[SpeedBoost] Ended');
}
/// 속도 부스트 수동 취소
///
/// Returns: 업데이트된 monetization
MonetizationState cancelSpeedBoost({
required ProgressLoop? loop,
required MonetizationState monetization,
}) {
if (_isSpeedBoostActive) {
endSpeedBoost(loop: loop);
}
return monetization.copyWith(speedBoostEndMs: null);
}
/// 부스트 상태에 따른 초기 배속 설정 계산
///
/// startNew() 호출 시 사용
({List<int> speeds, int initialSpeed}) calculateInitialSpeeds({
required List<int> baseAvailableSpeeds,
required int baseSpeed,
}) {
if (_isSpeedBoostActive) {
// 부스트 상태: 부스트 배속만 사용, 기본 배속 저장
savedSpeedMultiplier = baseSpeed;
return (speeds: [speedBoostMultiplier], initialSpeed: speedBoostMultiplier);
}
// 일반 상태: 기본 배속 사용
return (speeds: baseAvailableSpeeds, initialSpeed: baseSpeed);
}
}

View File

@@ -785,7 +785,9 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
child: AsciiDisintegrateWidget( child: AsciiDisintegrateWidget(
characterLines: _deathAnimationMonsterLines!, characterLines: _deathAnimationMonsterLines!,
duration: const Duration(milliseconds: 800), duration: const Duration(milliseconds: 800),
textColor: widget.monsterGrade?.displayColor, textColor: widget.monsterGrade?.displayColorCode != null
? Color(widget.monsterGrade!.displayColorCode!)
: null,
onComplete: _onDeathAnimationComplete, onComplete: _onDeathAnimationComplete,
), ),
), ),

View File

@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
/// 레트로 스타일 확인 다이얼로그
class RetroConfirmDialog extends StatelessWidget {
const RetroConfirmDialog({
super.key,
required this.title,
required this.message,
required this.confirmText,
required this.cancelText,
required this.onConfirm,
required this.onCancel,
});
final String title;
final String message;
final String confirmText;
final String cancelText;
final VoidCallback onConfirm;
final VoidCallback onCancel;
@override
Widget build(BuildContext context) {
final background = RetroColors.backgroundOf(context);
final gold = RetroColors.goldOf(context);
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
constraints: const BoxConstraints(maxWidth: 360),
decoration: BoxDecoration(
color: background,
border: Border.all(color: gold, width: 3),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 타이틀
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: gold.withValues(alpha: 0.2),
child: Text(
title,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: gold,
),
textAlign: TextAlign.center,
),
),
// 메시지
Padding(
padding: const EdgeInsets.all(16),
child: Text(
message,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: RetroColors.textPrimaryOf(context),
height: 1.8,
),
textAlign: TextAlign.center,
),
),
// 버튼
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Row(
children: [
Expanded(
child: RetroTextButton(
text: cancelText,
isPrimary: false,
onPressed: onCancel,
),
),
const SizedBox(width: 8),
Expanded(
child: RetroTextButton(
text: confirmText,
onPressed: onConfirm,
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 레트로 스타일 선택 다이얼로그
class RetroSelectDialog extends StatelessWidget {
const RetroSelectDialog({
super.key,
required this.title,
required this.children,
});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final background = RetroColors.backgroundOf(context);
final gold = RetroColors.goldOf(context);
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
constraints: const BoxConstraints(maxWidth: 320),
decoration: BoxDecoration(
color: background,
border: Border.all(color: gold, width: 2),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 타이틀
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: gold.withValues(alpha: 0.2),
child: Text(
title,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: gold,
),
textAlign: TextAlign.center,
),
),
// 옵션 목록
Padding(
padding: const EdgeInsets.all(12),
child: Column(children: children),
),
],
),
),
);
}
}
/// 선택 옵션 아이템
class RetroOptionItem extends StatelessWidget {
const RetroOptionItem({
super.key,
required this.label,
required this.isSelected,
required this.onTap,
this.prefix,
});
final String label;
final bool isSelected;
final VoidCallback onTap;
final String? prefix;
@override
Widget build(BuildContext context) {
final gold = RetroColors.goldOf(context);
final border = RetroColors.borderOf(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: isSelected ? gold.withValues(alpha: 0.15) : Colors.transparent,
border: Border.all(
color: isSelected ? gold : border,
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
if (prefix != null) ...[
Text(prefix!, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 12),
],
Expanded(
child: Text(
label,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 18,
color: isSelected ? gold : RetroColors.textPrimaryOf(context),
),
),
),
if (isSelected) Icon(Icons.check, size: 16, color: gold),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
/// 레트로 스타일 사운드 설정 다이얼로그
class RetroSoundDialog extends StatelessWidget {
const RetroSoundDialog({
super.key,
required this.bgmVolume,
required this.sfxVolume,
required this.onBgmChanged,
required this.onSfxChanged,
});
final double bgmVolume;
final double sfxVolume;
final ValueChanged<double> onBgmChanged;
final ValueChanged<double> onSfxChanged;
@override
Widget build(BuildContext context) {
final background = RetroColors.backgroundOf(context);
final gold = RetroColors.goldOf(context);
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
constraints: const BoxConstraints(maxWidth: 360),
decoration: BoxDecoration(
color: background,
border: Border.all(color: gold, width: 2),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 타이틀
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: gold.withValues(alpha: 0.2),
child: Text(
L10n.of(context).soundTitle,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: gold,
),
textAlign: TextAlign.center,
),
),
// 슬라이더
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildVolumeSlider(
context,
icon: bgmVolume == 0 ? Icons.music_off : Icons.music_note,
label: L10n.of(context).bgmLabel,
value: bgmVolume,
onChanged: onBgmChanged,
),
const SizedBox(height: 16),
_buildVolumeSlider(
context,
icon: sfxVolume == 0 ? Icons.volume_off : Icons.volume_up,
label: L10n.of(context).sfxLabel,
value: sfxVolume,
onChanged: onSfxChanged,
),
],
),
),
// 확인 버튼
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: SizedBox(
width: double.infinity,
child: RetroTextButton(
text: L10n.of(context).ok,
onPressed: () => Navigator.pop(context),
),
),
),
],
),
),
);
}
Widget _buildVolumeSlider(
BuildContext context, {
required IconData icon,
required String label,
required double value,
required ValueChanged<double> onChanged,
}) {
final gold = RetroColors.goldOf(context);
final border = RetroColors.borderOf(context);
final percentage = (value * 100).round();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: gold),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: RetroColors.textPrimaryOf(context),
),
),
const Spacer(),
Text(
'$percentage%',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: gold,
),
),
],
),
const SizedBox(height: 8),
SliderTheme(
data: SliderThemeData(
trackHeight: 8,
activeTrackColor: gold,
inactiveTrackColor: border,
thumbColor: RetroColors.goldLightOf(context),
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
overlayColor: gold.withValues(alpha: 0.2),
trackShape: const RectangularSliderTrackShape(),
),
child: Slider(value: value, onChanged: onChanged, divisions: 10),
),
],
);
}
}

View File

@@ -756,9 +756,10 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
final gradePrefix = (isKillTask && grade != null) final gradePrefix = (isKillTask && grade != null)
? grade.displayPrefix ? grade.displayPrefix
: ''; : '';
final gradeColor = (isKillTask && grade != null) final gradeColorCode = (isKillTask && grade != null)
? grade.displayColor ? grade.displayColorCode
: null; : null;
final gradeColor = gradeColorCode != null ? Color(gradeColorCode) : null;
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,

View File

@@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 메뉴 섹션 타이틀
class RetroMenuSection extends StatelessWidget {
const RetroMenuSection({
super.key,
required this.title,
this.color,
});
final String title;
final Color? color;
@override
Widget build(BuildContext context) {
final gold = color ?? RetroColors.goldOf(context);
return Row(
children: [
Container(width: 4, height: 14, color: gold),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 9,
color: gold,
letterSpacing: 1,
),
),
],
);
}
}
/// 메뉴 아이템
class RetroMenuItem extends StatelessWidget {
const RetroMenuItem({
super.key,
required this.icon,
required this.iconColor,
required this.label,
this.value,
this.subtitle,
this.trailing,
this.onTap,
});
final IconData icon;
final Color iconColor;
final String label;
final String? value;
final String? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final border = RetroColors.borderOf(context);
final panelBg = RetroColors.panelBgOf(context);
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: panelBg,
border: Border.all(color: border, width: 1),
),
child: Row(
children: [
Icon(icon, size: 16, color: iconColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: RetroColors.textPrimaryOf(context),
),
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: RetroColors.textMutedOf(context),
),
),
],
],
),
),
if (value != null)
Text(
value!,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.goldOf(context),
),
),
if (trailing != null) trailing!,
],
),
),
);
}
}
/// 속도 선택 칩
///
/// - 배속 토글 버튼
/// - 부스트 활성화 중: 반투명, 비활성
/// - 부스트 비활성화: 불투명, 활성
class RetroSpeedChip extends StatelessWidget {
const RetroSpeedChip({
super.key,
required this.speed,
required this.isSelected,
required this.onTap,
this.isAdBased = false,
this.isDisabled = false,
});
final int speed;
final bool isSelected;
final VoidCallback onTap;
final bool isAdBased;
/// 비활성 상태 (반투명, 탭 무시)
final bool isDisabled;
@override
Widget build(BuildContext context) {
final gold = RetroColors.goldOf(context);
final warning = RetroColors.warningOf(context);
final border = RetroColors.borderOf(context);
// 비활성 상태면 반투명 처리
final opacity = isDisabled ? 0.4 : 1.0;
final Color bgColor;
final Color textColor;
final Color borderColor;
if (isSelected) {
bgColor = isAdBased
? warning.withValues(alpha: 0.3 * opacity)
: gold.withValues(alpha: 0.3 * opacity);
textColor = (isAdBased ? warning : gold).withValues(alpha: opacity);
borderColor = (isAdBased ? warning : gold).withValues(alpha: opacity);
} else if (isAdBased) {
bgColor = Colors.transparent;
textColor = warning.withValues(alpha: opacity);
borderColor = warning.withValues(alpha: opacity);
} else {
bgColor = Colors.transparent;
textColor = RetroColors.textMutedOf(context).withValues(alpha: opacity);
borderColor = border.withValues(alpha: opacity);
}
return GestureDetector(
// 비활성 상태면 탭 무시
onTap: isDisabled ? null : onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: bgColor,
border: Border.all(color: borderColor, width: isSelected ? 2 : 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isAdBased && !isSelected && !isDisabled)
Padding(
padding: const EdgeInsets.only(right: 2),
child: Text(
'',
style: TextStyle(fontSize: 7, color: warning),
),
),
Text(
'${speed}x',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 8,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: textColor,
),
),
],
),
),
);
}
}

View File

@@ -169,9 +169,10 @@ class TaskProgressPanel extends StatelessWidget {
final gradePrefix = (isKillTask && grade != null) final gradePrefix = (isKillTask && grade != null)
? grade.displayPrefix ? grade.displayPrefix
: ''; : '';
final gradeColor = (isKillTask && grade != null) final gradeColorCode = (isKillTask && grade != null)
? grade.displayColor ? grade.displayColorCode
: null; : null;
final gradeColor = gradeColorCode != null ? Color(gradeColorCode) : null;
return Text.rich( return Text.rich(
TextSpan( TextSpan(

View File

@@ -2,7 +2,6 @@ import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show FilteringTextInputFormatter;
import 'package:asciineverdie/data/class_data.dart'; import 'package:asciineverdie/data/class_data.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
@@ -14,9 +13,12 @@ import 'package:asciineverdie/src/core/model/class_traits.dart';
import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/race_traits.dart'; import 'package:asciineverdie/src/core/model/race_traits.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart'; import 'package:asciineverdie/src/core/util/deterministic_random.dart';
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/util/pq_logic.dart'; import 'package:asciineverdie/src/core/util/pq_logic.dart';
import 'package:asciineverdie/src/features/new_character/widgets/class_selection_section.dart';
import 'package:asciineverdie/src/features/new_character/widgets/name_input_section.dart';
import 'package:asciineverdie/src/features/new_character/widgets/race_preview.dart'; import 'package:asciineverdie/src/features/new_character/widgets/race_preview.dart';
import 'package:asciineverdie/src/features/new_character/widgets/race_selection_section.dart';
import 'package:asciineverdie/src/features/new_character/widgets/stats_section.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart'; import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
@@ -69,9 +71,6 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
// 굴리기/되돌리기 서비스 // 굴리기/되돌리기 서비스
final CharacterRollService _rollService = CharacterRollService.instance; final CharacterRollService _rollService = CharacterRollService.instance;
// 서비스 초기화 완료 여부
bool _isServiceInitialized = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -99,11 +98,6 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
/// 서비스 초기화 /// 서비스 초기화
Future<void> _initializeService() async { Future<void> _initializeService() async {
await _rollService.initialize(); await _rollService.initialize();
if (mounted) {
setState(() {
_isServiceInitialized = true;
});
}
} }
@override @override
@@ -227,9 +221,9 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: RetroColors.panelBg, backgroundColor: RetroColors.panelBg,
title: const Text( title: Text(
'RECHARGE ROLLS', L10n.of(context).rechargeRollsTitle,
style: TextStyle( style: const TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 14, fontSize: 14,
color: RetroColors.gold, color: RetroColors.gold,
@@ -237,8 +231,8 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
), ),
content: Text( content: Text(
isPaidUser isPaidUser
? 'Recharge 5 rolls for free?' ? L10n.of(context).rechargeRollsFree
: 'Watch an ad to recharge 5 rolls?', : L10n.of(context).rechargeRollsAd,
style: const TextStyle( style: const TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 12, fontSize: 12,
@@ -248,9 +242,9 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
child: const Text( child: Text(
'CANCEL', L10n.of(context).cancel.toUpperCase(),
style: TextStyle( style: const TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 11, fontSize: 11,
color: RetroColors.textDisabled, color: RetroColors.textDisabled,
@@ -266,9 +260,9 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
const Icon(Icons.play_circle, size: 14, color: RetroColors.gold), const Icon(Icons.play_circle, size: 14, color: RetroColors.gold),
const SizedBox(width: 4), const SizedBox(width: 4),
], ],
const Text( Text(
'RECHARGE', L10n.of(context).rechargeButton,
style: TextStyle( style: const TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 11, fontSize: 11,
color: RetroColors.gold, color: RetroColors.gold,
@@ -331,22 +325,6 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
}); });
} }
/// Total 값 계산
int get _total => _str + _con + _dex + _int + _wis + _cha;
/// Total 색상 결정 (원본 규칙)
/// 63+18(81) 이상 = 빨강, 4*18(72) 초과 = 노랑
/// 63-18(45) 이하 = 회색, 3*18(54) 미만 = 은색
/// 그 외 = 흰색
Color _getTotalColor() {
final total = _total;
if (total >= 81) return Colors.red;
if (total > 72) return Colors.yellow;
if (total <= 45) return Colors.grey;
if (total < 54) return Colors.grey.shade400;
return Colors.white;
}
/// Sold! 버튼 클릭 - 캐릭터 생성 완료 /// Sold! 버튼 클릭 - 캐릭터 생성 완료
/// 원본 Main.pas:1371-1388 RollCharacter 로직 /// 원본 Main.pas:1371-1388 RollCharacter 로직
void _onSold() { void _onSold() {
@@ -438,16 +416,32 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// 이름 입력 섹션 // 이름 입력 섹션
_buildNameSection(), NameInputSection(
controller: _nameController,
onGenerateName: _onGenerateName,
),
const SizedBox(height: 16), const SizedBox(height: 16),
// 능력치 섹션 // 능력치 섹션
_buildStatsSection(), StatsSection(
str: _str,
con: _con,
dex: _dex,
intelligence: _int,
wis: _wis,
cha: _cha,
canRoll: _rollService.canRoll,
canUndo: _rollService.canUndo,
rollsRemaining: _rollService.rollsRemaining,
availableUndos: _rollService.availableUndos,
onRoll: _onReroll,
onUndo: _onUnroll,
),
const SizedBox(height: 16), const SizedBox(height: 16),
// 종족 미리보기 (Phase 5: 종족별 캐릭터 애니메이션) // 종족 미리보기 (Phase 5: 종족별 캐릭터 애니메이션)
RetroPanel( RetroPanel(
title: 'PREVIEW', title: L10n.of(context).previewTitle,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Center( child: Center(
child: RacePreview(raceId: _races[_selectedRaceIndex].raceId), child: RacePreview(raceId: _races[_selectedRaceIndex].raceId),
@@ -459,9 +453,25 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded(child: _buildRaceSection()), Expanded(
child: RaceSelectionSection(
races: _races,
selectedIndex: _selectedRaceIndex,
scrollController: _raceScrollController,
onSelected: (index) =>
setState(() => _selectedRaceIndex = index),
),
),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded(child: _buildKlassSection()), Expanded(
child: ClassSelectionSection(
klasses: _klasses,
selectedIndex: _selectedKlassIndex,
scrollController: _klassScrollController,
onSelected: (index) =>
setState(() => _selectedKlassIndex = index),
),
),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -476,54 +486,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
// 디버그 전용: 치트 모드 토글 (100x 터보 배속) // 디버그 전용: 치트 모드 토글 (100x 터보 배속)
if (kDebugMode) ...[ if (kDebugMode) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
GestureDetector( _buildDebugCheatToggle(context),
onTap: () => setState(() => _cheatsEnabled = !_cheatsEnabled),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: _cheatsEnabled
? RetroColors.hpRed.withValues(alpha: 0.3)
: RetroColors.panelBg,
border: Border.all(
color: _cheatsEnabled
? RetroColors.hpRed
: RetroColors.panelBorderInner,
width: 2,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_cheatsEnabled
? Icons.bug_report
: Icons.bug_report_outlined,
size: 16,
color: _cheatsEnabled
? RetroColors.hpRed
: RetroColors.textDisabled,
),
const SizedBox(width: 8),
Flexible(
child: Text(
'DEBUG: TURBO (20x)',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: _cheatsEnabled
? RetroColors.hpRed
: RetroColors.textDisabled,
),
),
),
],
),
),
),
], ],
], ],
), ),
@@ -532,488 +495,45 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
); );
} }
Widget _buildNameSection() { /// 디버그 치트 토글 위젯
final l10n = L10n.of(context); Widget _buildDebugCheatToggle(BuildContext context) {
return RetroPanel(
title: 'NAME',
child: Row(
children: [
Expanded(
child: TextField(
controller: _nameController,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.textLight,
),
// 영문 알파벳만 허용 (공백 불가)
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z]')),
],
decoration: InputDecoration(
labelText: l10n.name,
labelStyle: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.gold,
),
border: const OutlineInputBorder(
borderSide: BorderSide(color: RetroColors.panelBorderInner),
),
enabledBorder: const OutlineInputBorder(
borderSide: BorderSide(color: RetroColors.panelBorderInner),
),
focusedBorder: const OutlineInputBorder(
borderSide: BorderSide(color: RetroColors.gold, width: 2),
),
counterStyle: const TextStyle(color: RetroColors.textDisabled),
),
maxLength: 30,
),
),
const SizedBox(width: 8),
RetroIconButton(icon: Icons.casino, onPressed: _onGenerateName),
],
),
);
}
Widget _buildStatsSection() {
final l10n = L10n.of(context);
return RetroPanel(
title: 'STATS',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 스탯 그리드
Row(
children: [
Expanded(child: _buildStatTile(l10n.statStr, _str)),
Expanded(child: _buildStatTile(l10n.statCon, _con)),
Expanded(child: _buildStatTile(l10n.statDex, _dex)),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: _buildStatTile(l10n.statInt, _int)),
Expanded(child: _buildStatTile(l10n.statWis, _wis)),
Expanded(child: _buildStatTile(l10n.statCha, _cha)),
],
),
const SizedBox(height: 12),
// Total
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _getTotalColor().withValues(alpha: 0.2),
border: Border.all(color: _getTotalColor(), width: 2),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.total.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
fontWeight: FontWeight.bold,
color: RetroColors.textLight,
),
),
Text(
'$_total',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
fontWeight: FontWeight.bold,
color: _getTotalColor(),
),
),
],
),
),
const SizedBox(height: 12),
// Roll 버튼들
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildUndoButton(l10n),
const SizedBox(width: 16),
_buildRollButton(l10n),
],
),
// 남은 횟수 표시
Padding(
padding: const EdgeInsets.only(top: 8),
child: Center(
child: Text(
_rollService.canUndo
? 'Undo: ${_rollService.availableUndos} | Rolls: ${_rollService.rollsRemaining}/5'
: 'Rolls: ${_rollService.rollsRemaining}/5',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.textDisabled,
),
),
),
),
],
),
);
}
Widget _buildStatTile(String label, int value) {
return Container(
margin: const EdgeInsets.all(4),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: RetroColors.panelBgLight,
border: Border.all(color: RetroColors.panelBorderInner),
),
child: Column(
children: [
Text(
label.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.gold,
),
),
const SizedBox(height: 4),
Text(
'$value',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 17,
fontWeight: FontWeight.bold,
color: RetroColors.textLight,
),
),
],
),
);
}
Widget _buildRaceSection() {
return RetroPanel(
title: 'RACE',
child: SizedBox(
height: 300,
child: ListView.builder(
controller: _raceScrollController,
itemCount: _races.length,
itemBuilder: (context, index) {
final isSelected = index == _selectedRaceIndex;
final race = _races[index];
return GestureDetector(
onTap: () => setState(() => _selectedRaceIndex = index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? RetroColors.panelBgLight : null,
border: isSelected
? Border.all(color: RetroColors.gold, width: 1)
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
isSelected ? Icons.arrow_right : Icons.remove,
size: 12,
color: isSelected
? RetroColors.gold
: RetroColors.textDisabled,
),
const SizedBox(width: 4),
Expanded(
child: Text(
GameDataL10n.getRaceName(context, race.name),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: isSelected
? RetroColors.gold
: RetroColors.textLight,
),
),
),
],
),
if (isSelected) _buildRaceInfo(race),
],
),
),
);
},
),
),
);
}
/// 종족 정보 표시 (Phase 5)
Widget _buildRaceInfo(RaceTraits race) {
final statMods = <String>[];
for (final entry in race.statModifiers.entries) {
final sign = entry.value > 0 ? '+' : '';
statMods.add('${_statName(entry.key)} $sign${entry.value}');
}
final passiveDesc = race.passives.isNotEmpty
? race.passives.map((p) => _translateRacePassive(p)).join(', ')
: '';
return Padding(
padding: const EdgeInsets.only(left: 16, top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (statMods.isNotEmpty)
Text(
statMods.join(', '),
style: const TextStyle(
fontSize: 15,
color: RetroColors.textLight,
),
),
if (passiveDesc.isNotEmpty)
Text(
passiveDesc,
style: const TextStyle(fontSize: 15, color: RetroColors.expGreen),
),
],
),
);
}
/// 종족 패시브 설명 번역
String _translateRacePassive(PassiveAbility passive) {
final percent = (passive.value * 100).round();
return switch (passive.type) {
PassiveType.hpBonus => game_l10n.passiveHpBonus(percent),
PassiveType.mpBonus => game_l10n.passiveMpBonus(percent),
PassiveType.defenseBonus => game_l10n.passiveDefenseBonus(percent),
PassiveType.magicDamageBonus => game_l10n.passiveMagicBonus(percent),
PassiveType.criticalBonus => game_l10n.passiveCritBonus(percent),
PassiveType.expBonus => passive.description,
};
}
String _statName(StatType type) {
return switch (type) {
StatType.str => game_l10n.statStr,
StatType.con => game_l10n.statCon,
StatType.dex => game_l10n.statDex,
StatType.intelligence => game_l10n.statInt,
StatType.wis => game_l10n.statWis,
StatType.cha => game_l10n.statCha,
};
}
Widget _buildKlassSection() {
return RetroPanel(
title: 'CLASS',
child: SizedBox(
height: 300,
child: ListView.builder(
controller: _klassScrollController,
itemCount: _klasses.length,
itemBuilder: (context, index) {
final isSelected = index == _selectedKlassIndex;
final klass = _klasses[index];
return GestureDetector(
onTap: () => setState(() => _selectedKlassIndex = index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? RetroColors.panelBgLight : null,
border: isSelected
? Border.all(color: RetroColors.gold, width: 1)
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
isSelected ? Icons.arrow_right : Icons.remove,
size: 12,
color: isSelected
? RetroColors.gold
: RetroColors.textDisabled,
),
const SizedBox(width: 4),
Expanded(
child: Text(
GameDataL10n.getKlassName(context, klass.name),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: isSelected
? RetroColors.gold
: RetroColors.textLight,
),
),
),
],
),
if (isSelected) _buildClassInfo(klass),
],
),
),
);
},
),
),
);
}
/// 클래스 정보 표시 (Phase 5)
Widget _buildClassInfo(ClassTraits klass) {
final statMods = <String>[];
for (final entry in klass.statModifiers.entries) {
final sign = entry.value > 0 ? '+' : '';
statMods.add('${_statName(entry.key)} $sign${entry.value}');
}
final passiveDesc = klass.passives.isNotEmpty
? klass.passives.map((p) => _translateClassPassive(p)).join(', ')
: '';
return Padding(
padding: const EdgeInsets.only(left: 16, top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (statMods.isNotEmpty)
Text(
statMods.join(', '),
style: const TextStyle(
fontSize: 15,
color: RetroColors.textLight,
),
),
if (passiveDesc.isNotEmpty)
Text(
passiveDesc,
style: const TextStyle(fontSize: 15, color: RetroColors.expGreen),
),
],
),
);
}
/// 클래스 패시브 설명 번역
String _translateClassPassive(ClassPassive passive) {
final percent = (passive.value * 100).round();
return switch (passive.type) {
ClassPassiveType.hpBonus => game_l10n.passiveHpBonus(percent),
ClassPassiveType.physicalDamageBonus => game_l10n.passivePhysicalBonus(
percent,
),
ClassPassiveType.defenseBonus => game_l10n.passiveDefenseBonus(percent),
ClassPassiveType.magicDamageBonus => game_l10n.passiveMagicBonus(percent),
ClassPassiveType.evasionBonus => game_l10n.passiveEvasionBonus(percent),
ClassPassiveType.criticalBonus => game_l10n.passiveCritBonus(percent),
ClassPassiveType.postCombatHeal => game_l10n.passiveHpRegen(percent),
ClassPassiveType.healingBonus => passive.description,
ClassPassiveType.multiAttack => passive.description,
ClassPassiveType.firstStrikeBonus => passive.description,
};
}
// ===========================================================================
// 굴리기/되돌리기 버튼 위젯
// ===========================================================================
/// 되돌리기 버튼
Widget _buildUndoButton(L10n l10n) {
final canUndo = _rollService.canUndo;
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
return GestureDetector( return GestureDetector(
onTap: canUndo ? _onUnroll : null, onTap: () => setState(() => _cheatsEnabled = !_cheatsEnabled),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: canUndo color: _cheatsEnabled
? RetroColors.panelBgLight ? RetroColors.hpRed.withValues(alpha: 0.3)
: RetroColors.panelBg.withValues(alpha: 0.5), : RetroColors.panelBg,
border: Border.all( border: Border.all(
color: canUndo ? RetroColors.panelBorderInner : RetroColors.panelBg, color: _cheatsEnabled
? RetroColors.hpRed
: RetroColors.panelBorderInner,
width: 2, width: 2,
), ),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// 무료 유저는 광고 아이콘 표시
if (!isPaidUser && canUndo) ...[
const Icon(
Icons.play_circle,
size: 14,
color: RetroColors.gold,
),
const SizedBox(width: 4),
],
Icon( Icon(
Icons.undo, _cheatsEnabled ? Icons.bug_report : Icons.bug_report_outlined,
size: 14, size: 16,
color: canUndo ? RetroColors.textLight : RetroColors.textDisabled, color: _cheatsEnabled
? RetroColors.hpRed
: RetroColors.textDisabled,
), ),
const SizedBox(width: 4), const SizedBox(width: 8),
Text( Flexible(
l10n.unroll.toUpperCase(), child: Text(
style: TextStyle( L10n.of(context).debugTurbo,
fontFamily: 'PressStart2P', overflow: TextOverflow.ellipsis,
fontSize: 11, style: TextStyle(
color: canUndo ? RetroColors.textLight : RetroColors.textDisabled, fontFamily: 'PressStart2P',
), fontSize: 11,
), color: _cheatsEnabled
], ? RetroColors.hpRed
), : RetroColors.textDisabled,
), ),
);
}
/// 굴리기 버튼
Widget _buildRollButton(L10n l10n) {
final canRoll = _rollService.canRoll;
return GestureDetector(
onTap: _onReroll,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: RetroColors.panelBgLight,
border: Border.all(color: RetroColors.gold, width: 2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 0회일 때 광고 아이콘 표시
if (!canRoll) ...[
const Icon(Icons.play_circle, size: 14, color: RetroColors.gold),
const SizedBox(width: 4),
],
const Icon(Icons.casino, size: 14, color: RetroColors.gold),
const SizedBox(width: 4),
Text(
canRoll
? '${l10n.roll.toUpperCase()} (${_rollService.rollsRemaining})'
: l10n.roll.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.gold,
), ),
), ),
], ],

View File

@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/class_traits.dart';
import 'package:asciineverdie/src/core/model/race_traits.dart' show StatType;
import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
/// 직업 선택 섹션
class ClassSelectionSection extends StatelessWidget {
const ClassSelectionSection({
super.key,
required this.klasses,
required this.selectedIndex,
required this.scrollController,
required this.onSelected,
});
final List<ClassTraits> klasses;
final int selectedIndex;
final ScrollController scrollController;
final ValueChanged<int> onSelected;
@override
Widget build(BuildContext context) {
return RetroPanel(
title: L10n.of(context).classSection,
child: SizedBox(
height: 300,
child: ListView.builder(
controller: scrollController,
itemCount: klasses.length,
itemBuilder: (context, index) {
final isSelected = index == selectedIndex;
final klass = klasses[index];
return GestureDetector(
onTap: () => onSelected(index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? RetroColors.panelBgLight : null,
border: isSelected
? Border.all(color: RetroColors.gold, width: 1)
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
isSelected ? Icons.arrow_right : Icons.remove,
size: 12,
color: isSelected
? RetroColors.gold
: RetroColors.textDisabled,
),
const SizedBox(width: 4),
Expanded(
child: Text(
GameDataL10n.getKlassName(context, klass.name),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: isSelected
? RetroColors.gold
: RetroColors.textLight,
),
),
),
],
),
if (isSelected) _ClassInfo(klass: klass),
],
),
),
);
},
),
),
);
}
}
/// 클래스 정보 표시 위젯
class _ClassInfo extends StatelessWidget {
const _ClassInfo({required this.klass});
final ClassTraits klass;
String _statName(StatType type) {
return switch (type) {
StatType.str => game_l10n.statStr,
StatType.con => game_l10n.statCon,
StatType.dex => game_l10n.statDex,
StatType.intelligence => game_l10n.statInt,
StatType.wis => game_l10n.statWis,
StatType.cha => game_l10n.statCha,
};
}
String _translatePassive(ClassPassive passive) {
final percent = (passive.value * 100).round();
return switch (passive.type) {
ClassPassiveType.hpBonus => game_l10n.passiveHpBonus(percent),
ClassPassiveType.physicalDamageBonus =>
game_l10n.passivePhysicalBonus(percent),
ClassPassiveType.defenseBonus => game_l10n.passiveDefenseBonus(percent),
ClassPassiveType.magicDamageBonus => game_l10n.passiveMagicBonus(percent),
ClassPassiveType.evasionBonus => game_l10n.passiveEvasionBonus(percent),
ClassPassiveType.criticalBonus => game_l10n.passiveCritBonus(percent),
ClassPassiveType.postCombatHeal => game_l10n.passiveHpRegen(percent),
ClassPassiveType.healingBonus => passive.description,
ClassPassiveType.multiAttack => passive.description,
ClassPassiveType.firstStrikeBonus => passive.description,
};
}
@override
Widget build(BuildContext context) {
final statMods = <String>[];
for (final entry in klass.statModifiers.entries) {
final sign = entry.value > 0 ? '+' : '';
statMods.add('${_statName(entry.key)} $sign${entry.value}');
}
final passiveDesc = klass.passives.isNotEmpty
? klass.passives.map((p) => _translatePassive(p)).join(', ')
: '';
return Padding(
padding: const EdgeInsets.only(left: 16, top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (statMods.isNotEmpty)
Text(
statMods.join(', '),
style: const TextStyle(
fontSize: 15,
color: RetroColors.textLight,
),
),
if (passiveDesc.isNotEmpty)
Text(
passiveDesc,
style: const TextStyle(fontSize: 15, color: RetroColors.expGreen),
),
],
),
);
}
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show FilteringTextInputFormatter;
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
/// 캐릭터 이름 입력 섹션
class NameInputSection extends StatelessWidget {
const NameInputSection({
super.key,
required this.controller,
required this.onGenerateName,
});
final TextEditingController controller;
final VoidCallback onGenerateName;
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
return RetroPanel(
title: l10n.nameTitle,
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.textLight,
),
// 영문 알파벳만 허용 (공백 불가)
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z]')),
],
decoration: InputDecoration(
labelText: l10n.name,
labelStyle: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.gold,
),
border: const OutlineInputBorder(
borderSide: BorderSide(color: RetroColors.panelBorderInner),
),
enabledBorder: const OutlineInputBorder(
borderSide: BorderSide(color: RetroColors.panelBorderInner),
),
focusedBorder: const OutlineInputBorder(
borderSide: BorderSide(color: RetroColors.gold, width: 2),
),
counterStyle: const TextStyle(color: RetroColors.textDisabled),
),
maxLength: 30,
),
),
const SizedBox(width: 8),
RetroIconButton(icon: Icons.casino, onPressed: onGenerateName),
],
),
);
}
}

View File

@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/race_traits.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
/// 종족 선택 섹션
class RaceSelectionSection extends StatelessWidget {
const RaceSelectionSection({
super.key,
required this.races,
required this.selectedIndex,
required this.scrollController,
required this.onSelected,
});
final List<RaceTraits> races;
final int selectedIndex;
final ScrollController scrollController;
final ValueChanged<int> onSelected;
@override
Widget build(BuildContext context) {
return RetroPanel(
title: L10n.of(context).raceTitle,
child: SizedBox(
height: 300,
child: ListView.builder(
controller: scrollController,
itemCount: races.length,
itemBuilder: (context, index) {
final isSelected = index == selectedIndex;
final race = races[index];
return GestureDetector(
onTap: () => onSelected(index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? RetroColors.panelBgLight : null,
border: isSelected
? Border.all(color: RetroColors.gold, width: 1)
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
isSelected ? Icons.arrow_right : Icons.remove,
size: 12,
color: isSelected
? RetroColors.gold
: RetroColors.textDisabled,
),
const SizedBox(width: 4),
Expanded(
child: Text(
GameDataL10n.getRaceName(context, race.name),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: isSelected
? RetroColors.gold
: RetroColors.textLight,
),
),
),
],
),
if (isSelected) _RaceInfo(race: race),
],
),
),
);
},
),
),
);
}
}
/// 종족 정보 표시 위젯
class _RaceInfo extends StatelessWidget {
const _RaceInfo({required this.race});
final RaceTraits race;
String _statName(StatType type) {
return switch (type) {
StatType.str => game_l10n.statStr,
StatType.con => game_l10n.statCon,
StatType.dex => game_l10n.statDex,
StatType.intelligence => game_l10n.statInt,
StatType.wis => game_l10n.statWis,
StatType.cha => game_l10n.statCha,
};
}
String _translatePassive(PassiveAbility passive) {
final percent = (passive.value * 100).round();
return switch (passive.type) {
PassiveType.hpBonus => game_l10n.passiveHpBonus(percent),
PassiveType.mpBonus => game_l10n.passiveMpBonus(percent),
PassiveType.defenseBonus => game_l10n.passiveDefenseBonus(percent),
PassiveType.magicDamageBonus => game_l10n.passiveMagicBonus(percent),
PassiveType.criticalBonus => game_l10n.passiveCritBonus(percent),
PassiveType.expBonus => passive.description,
};
}
@override
Widget build(BuildContext context) {
final statMods = <String>[];
for (final entry in race.statModifiers.entries) {
final sign = entry.value > 0 ? '+' : '';
statMods.add('${_statName(entry.key)} $sign${entry.value}');
}
final passiveDesc = race.passives.isNotEmpty
? race.passives.map((p) => _translatePassive(p)).join(', ')
: '';
return Padding(
padding: const EdgeInsets.only(left: 16, top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (statMods.isNotEmpty)
Text(
statMods.join(', '),
style: const TextStyle(
fontSize: 15,
color: RetroColors.textLight,
),
),
if (passiveDesc.isNotEmpty)
Text(
passiveDesc,
style: const TextStyle(fontSize: 15, color: RetroColors.expGreen),
),
],
),
);
}
}

View File

@@ -0,0 +1,302 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
/// 능력치 표시 섹션
class StatsSection extends StatelessWidget {
const StatsSection({
super.key,
required this.str,
required this.con,
required this.dex,
required this.intelligence,
required this.wis,
required this.cha,
required this.canRoll,
required this.canUndo,
required this.rollsRemaining,
required this.availableUndos,
required this.onRoll,
required this.onUndo,
});
final int str;
final int con;
final int dex;
final int intelligence;
final int wis;
final int cha;
final bool canRoll;
final bool canUndo;
final int rollsRemaining;
final int availableUndos;
final VoidCallback onRoll;
final VoidCallback onUndo;
int get _total => str + con + dex + intelligence + wis + cha;
/// Total 색상 결정 (원본 규칙)
/// 63+18(81) 이상 = 빨강, 4*18(72) 초과 = 노랑
/// 63-18(45) 이하 = 회색, 3*18(54) 미만 = 은색
/// 그 외 = 흰색
Color _getTotalColor() {
final total = _total;
if (total >= 81) return Colors.red;
if (total > 72) return Colors.yellow;
if (total <= 45) return Colors.grey;
if (total < 54) return Colors.grey.shade400;
return Colors.white;
}
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
return RetroPanel(
title: l10n.statsTitle,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 스탯 그리드
Row(
children: [
Expanded(child: _StatTile(label: l10n.statStr, value: str)),
Expanded(child: _StatTile(label: l10n.statCon, value: con)),
Expanded(child: _StatTile(label: l10n.statDex, value: dex)),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _StatTile(label: l10n.statInt, value: intelligence),
),
Expanded(child: _StatTile(label: l10n.statWis, value: wis)),
Expanded(child: _StatTile(label: l10n.statCha, value: cha)),
],
),
const SizedBox(height: 12),
// Total
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _getTotalColor().withValues(alpha: 0.2),
border: Border.all(color: _getTotalColor(), width: 2),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.total.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
fontWeight: FontWeight.bold,
color: RetroColors.textLight,
),
),
Text(
'$_total',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
fontWeight: FontWeight.bold,
color: _getTotalColor(),
),
),
],
),
),
const SizedBox(height: 12),
// Roll 버튼들
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_UndoButton(
canUndo: canUndo,
onPressed: onUndo,
),
const SizedBox(width: 16),
_RollButton(
canRoll: canRoll,
rollsRemaining: rollsRemaining,
onPressed: onRoll,
),
],
),
// 남은 횟수 표시
Padding(
padding: const EdgeInsets.only(top: 8),
child: Center(
child: Text(
canUndo
? 'Undo: $availableUndos | Rolls: $rollsRemaining/5'
: 'Rolls: $rollsRemaining/5',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.textDisabled,
),
),
),
),
],
),
);
}
}
/// 스탯 타일 위젯
class _StatTile extends StatelessWidget {
const _StatTile({required this.label, required this.value});
final String label;
final int value;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.all(4),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: RetroColors.panelBgLight,
border: Border.all(color: RetroColors.panelBorderInner),
),
child: Column(
children: [
Text(
label.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.gold,
),
),
const SizedBox(height: 4),
Text(
'$value',
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 17,
fontWeight: FontWeight.bold,
color: RetroColors.textLight,
),
),
],
),
);
}
}
/// 되돌리기 버튼
class _UndoButton extends StatelessWidget {
const _UndoButton({required this.canUndo, required this.onPressed});
final bool canUndo;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
final isPaidUser = IAPService.instance.isAdRemovalPurchased;
return GestureDetector(
onTap: canUndo ? onPressed : null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: canUndo
? RetroColors.panelBgLight
: RetroColors.panelBg.withValues(alpha: 0.5),
border: Border.all(
color: canUndo ? RetroColors.panelBorderInner : RetroColors.panelBg,
width: 2,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 무료 유저는 광고 아이콘 표시
if (!isPaidUser && canUndo) ...[
const Icon(
Icons.play_circle,
size: 14,
color: RetroColors.gold,
),
const SizedBox(width: 4),
],
Icon(
Icons.undo,
size: 14,
color: canUndo ? RetroColors.textLight : RetroColors.textDisabled,
),
const SizedBox(width: 4),
Text(
l10n.unroll.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: canUndo ? RetroColors.textLight : RetroColors.textDisabled,
),
),
],
),
),
);
}
}
/// 굴리기 버튼
class _RollButton extends StatelessWidget {
const _RollButton({
required this.canRoll,
required this.rollsRemaining,
required this.onPressed,
});
final bool canRoll;
final int rollsRemaining;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
return GestureDetector(
onTap: onPressed,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: RetroColors.panelBgLight,
border: Border.all(color: RetroColors.gold, width: 2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 0회일 때 광고 아이콘 표시
if (!canRoll) ...[
const Icon(Icons.play_circle, size: 14, color: RetroColors.gold),
const SizedBox(width: 4),
],
const Icon(Icons.casino, size: 14, color: RetroColors.gold),
const SizedBox(width: 4),
Text(
canRoll
? '${l10n.roll.toUpperCase()} ($rollsRemaining)'
: l10n.roll.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.gold,
),
),
],
),
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n; import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/engine/debug_settings_service.dart'; import 'package:asciineverdie/src/core/engine/debug_settings_service.dart';
import 'package:asciineverdie/src/core/storage/settings_repository.dart'; import 'package:asciineverdie/src/core/storage/settings_repository.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart';
@@ -145,7 +146,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// 디버그 섹션 (디버그 모드에서만 표시) // 디버그 섹션 (디버그 모드에서만 표시)
if (kDebugMode) ...[ if (kDebugMode) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
const _RetroSectionTitle(title: 'DEBUG'), _RetroSectionTitle(title: L10n.of(context).debugTitle),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildDebugSection(context), _buildDebugSection(context),
], ],
@@ -308,7 +309,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'DEVELOPER TOOLS', L10n.of(context).debugDeveloperTools,
style: TextStyle( style: TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 10, fontSize: 10,
@@ -322,8 +323,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
// IAP 시뮬레이션 토글 // IAP 시뮬레이션 토글
_RetroDebugToggle( _RetroDebugToggle(
icon: Icons.shopping_cart, icon: Icons.shopping_cart,
label: 'IAP PURCHASED', label: L10n.of(context).debugIapPurchased,
description: 'ON: 유료 유저로 동작 (광고 제거)', description: L10n.of(context).debugIapPurchasedDesc,
value: _debugIapSimulated, value: _debugIapSimulated,
onChanged: (value) async { onChanged: (value) async {
await DebugSettingsService.instance.setIapSimulated(value); await DebugSettingsService.instance.setIapSimulated(value);
@@ -346,7 +347,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// 테스트 캐릭터 생성 // 테스트 캐릭터 생성
if (widget.onCreateTestCharacter != null) ...[ if (widget.onCreateTestCharacter != null) ...[
Text( Text(
'현재 캐릭터를 레벨 100으로 수정하여\n명예의 전당에 등록합니다.', L10n.of(context).debugTestCharacterDesc,
style: TextStyle( style: TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 7, fontSize: 7,
@@ -358,7 +359,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: RetroTextButton( child: RetroTextButton(
text: 'CREATE TEST CHARACTER', text: L10n.of(context).debugCreateTestCharacter,
icon: Icons.science, icon: Icons.science,
onPressed: _handleCreateTestCharacter, onPressed: _handleCreateTestCharacter,
), ),
@@ -388,7 +389,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'OFFLINE HOURS', L10n.of(context).debugOfflineHours,
style: TextStyle( style: TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 8, fontSize: 8,
@@ -397,7 +398,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'복귀 보상 테스트 (재시작 시 적용)', L10n.of(context).debugOfflineHoursDesc,
style: TextStyle( style: TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 6, fontSize: 6,
@@ -435,14 +436,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) => _RetroConfirmDialog( builder: (context) => _RetroConfirmDialog(
title: 'CREATE TEST CHARACTER?', title: L10n.of(context).debugCreateTestCharacterTitle,
message: message: L10n.of(context).debugCreateTestCharacterMessage,
'현재 캐릭터가 레벨 100으로 변환되어\n' confirmText: L10n.of(context).createButton,
'명예의 전당에 등록됩니다.\n\n' cancelText: L10n.of(context).cancel.toUpperCase(),
'⚠️ 현재 세이브 파일이 삭제됩니다.\n'
'이 작업은 되돌릴 수 없습니다.',
confirmText: 'CREATE',
cancelText: 'CANCEL',
), ),
); );

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/shared/retro_theme_constants.dart';
/// 패널 헤더 변형 /// 패널 헤더 변형
enum PanelHeaderVariant { enum PanelHeaderVariant {