Compare commits
9 Commits
7b9f1f87a6
...
d9a2fe358c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9a2fe358c | ||
|
|
faf87eccb0 | ||
|
|
7f44e95163 | ||
|
|
742b0d1773 | ||
|
|
97b40ccb1f | ||
|
|
75bc39528f | ||
|
|
c5eaecfa6a | ||
|
|
c577f9deed | ||
|
|
e516076ce8 |
46
CHANGELOG.md
Normal file
46
CHANGELOG.md
Normal 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
208
docs/ARCHITECTURE.md
Normal 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` |
|
||||
@@ -1226,3 +1226,23 @@ String get elementChaos => _l('Chaos', '혼돈', 'カオス');
|
||||
|
||||
// 스킬 상세 정보 없음
|
||||
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!', '보스 처치!', 'ボス撃破!');
|
||||
|
||||
@@ -310,5 +310,168 @@
|
||||
"@endingTapToSkip": { "description": "Tap to skip hint" },
|
||||
|
||||
"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" }
|
||||
}
|
||||
|
||||
@@ -93,5 +93,57 @@
|
||||
"endingHallOfFameButton": "殿堂入り",
|
||||
"endingSkip": "スキップ",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -93,5 +93,57 @@
|
||||
"endingHallOfFameButton": "명예의 전당",
|
||||
"endingSkip": "건너뛰기",
|
||||
"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명예의 전당에 등록합니다."
|
||||
}
|
||||
|
||||
@@ -652,6 +652,312 @@ abstract class L10n {
|
||||
/// In en, this message translates to:
|
||||
/// **'HOLD TO SPEED UP'**
|
||||
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> {
|
||||
|
||||
@@ -297,4 +297,165 @@ class L10nEn extends L10n {
|
||||
|
||||
@override
|
||||
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.';
|
||||
}
|
||||
|
||||
@@ -297,4 +297,165 @@ class L10nJa extends L10n {
|
||||
|
||||
@override
|
||||
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.';
|
||||
}
|
||||
|
||||
@@ -297,4 +297,162 @@ class L10nKo extends L10n {
|
||||
|
||||
@override
|
||||
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명예의 전당에 등록합니다.';
|
||||
}
|
||||
|
||||
@@ -297,4 +297,165 @@ class L10nZh extends L10n {
|
||||
|
||||
@override
|
||||
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.';
|
||||
}
|
||||
|
||||
@@ -93,5 +93,57 @@
|
||||
"endingHallOfFameButton": "荣誉殿堂",
|
||||
"endingSkip": "跳过",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -161,36 +161,107 @@ class ProgressService {
|
||||
|
||||
/// Tick the timer loop (equivalent to Timer1Timer in the original code).
|
||||
ProgressTickResult tick(GameState state, int elapsedMillis) {
|
||||
// 10000ms 제한: 100x 배속 (50ms * 100 = 5000ms) + 여유 공간
|
||||
// 디버그 터보 모드(100x) 지원을 위해 확장
|
||||
final int clamped = elapsedMillis.clamp(0, 10000).toInt();
|
||||
var progress = state.progress;
|
||||
var queue = state.queue;
|
||||
var nextState = state;
|
||||
|
||||
// 1. 스킬 시스템 업데이트 (시간, 버프, MP 회복)
|
||||
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 questDone = false;
|
||||
var actDone = 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);
|
||||
var skillSystem = skillService.updateElapsedTime(
|
||||
state.skillSystem,
|
||||
clamped,
|
||||
elapsedMs,
|
||||
);
|
||||
|
||||
// 만료된 버프 정리
|
||||
skillSystem = skillService.cleanupExpiredBuffs(skillSystem);
|
||||
|
||||
var nextState = state.copyWith(skillSystem: skillSystem);
|
||||
|
||||
// 비전투 시 MP 회복
|
||||
final isInCombat =
|
||||
progress.currentTask.type == TaskType.kill &&
|
||||
progress.currentCombat != null &&
|
||||
progress.currentCombat!.isActive;
|
||||
state.progress.currentTask.type == TaskType.kill &&
|
||||
state.progress.currentCombat != null &&
|
||||
state.progress.currentCombat!.isActive;
|
||||
|
||||
if (!isInCombat && nextState.stats.mp < nextState.stats.mpMax) {
|
||||
final mpRegen = skillService.calculateMpRegen(
|
||||
elapsedMs: clamped,
|
||||
elapsedMs: elapsedMs,
|
||||
isInCombat: false,
|
||||
wis: nextState.stats.wis,
|
||||
);
|
||||
@@ -205,28 +276,32 @@ class ProgressService {
|
||||
}
|
||||
}
|
||||
|
||||
nextState = nextState.copyWith(skillSystem: skillSystem);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
// Advance task bar if still running.
|
||||
if (progress.task.position < progress.task.max) {
|
||||
final uncapped = progress.task.position + clamped;
|
||||
/// 태스크 진행 중 처리 (전투 틱 포함)
|
||||
ProgressTickResult _processTaskInProgress(GameState state, int elapsedMs) {
|
||||
var progress = state.progress;
|
||||
final uncapped = progress.task.position + elapsedMs;
|
||||
final int newTaskPos = uncapped > progress.task.max
|
||||
? progress.task.max
|
||||
: uncapped;
|
||||
|
||||
// 킬 태스크 중 전투 진행 (CombatTickService 사용)
|
||||
var updatedCombat = progress.currentCombat;
|
||||
var updatedSkillSystem = nextState.skillSystem;
|
||||
var updatedPotionInventory = nextState.potionInventory;
|
||||
var updatedSkillSystem = state.skillSystem;
|
||||
var updatedPotionInventory = state.potionInventory;
|
||||
var nextState = state;
|
||||
|
||||
// 킬 태스크 중 전투 진행
|
||||
if (progress.currentTask.type == TaskType.kill &&
|
||||
updatedCombat != null &&
|
||||
updatedCombat.isActive) {
|
||||
final combatTickService = CombatTickService(rng: nextState.rng);
|
||||
final combatTickService = CombatTickService(rng: state.rng);
|
||||
final combatResult = combatTickService.processTick(
|
||||
state: nextState,
|
||||
state: state,
|
||||
combat: updatedCombat,
|
||||
skillSystem: updatedSkillSystem,
|
||||
elapsedMs: clamped,
|
||||
elapsedMs: elapsedMs,
|
||||
);
|
||||
updatedCombat = combatResult.combat;
|
||||
updatedSkillSystem = combatResult.skillSystem;
|
||||
@@ -234,11 +309,11 @@ class ProgressService {
|
||||
updatedPotionInventory = combatResult.potionInventory!;
|
||||
}
|
||||
|
||||
// Phase 4: 플레이어 사망 체크
|
||||
// 플레이어 사망 체크
|
||||
if (!updatedCombat.playerStats.isAlive) {
|
||||
final monsterName = updatedCombat.monsterStats.name;
|
||||
nextState = _processPlayerDeath(
|
||||
nextState,
|
||||
state,
|
||||
killerName: monsterName,
|
||||
cause: DeathCause.monster,
|
||||
);
|
||||
@@ -251,7 +326,7 @@ class ProgressService {
|
||||
currentCombat: updatedCombat,
|
||||
);
|
||||
nextState = _recalculateEncumbrance(
|
||||
nextState.copyWith(
|
||||
state.copyWith(
|
||||
progress: progress,
|
||||
skillSystem: updatedSkillSystem,
|
||||
potionInventory: updatedPotionInventory,
|
||||
@@ -260,28 +335,27 @@ class ProgressService {
|
||||
return ProgressTickResult(state: nextState);
|
||||
}
|
||||
|
||||
final gain = progress.currentTask.type == TaskType.kill;
|
||||
final incrementSeconds = progress.task.max ~/ 1000;
|
||||
/// 킬 태스크 완료 처리 (HP 회복, 전리품, 보스 처리)
|
||||
({
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
ProgressTickResult? earlyReturn,
|
||||
}) _handleKillTaskCompletion(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
var nextState = state;
|
||||
|
||||
// 몬스터 경험치 미리 저장 (currentCombat이 null되기 전)
|
||||
final int monsterExpReward =
|
||||
progress.currentCombat?.monsterStats.expReward ?? 0;
|
||||
|
||||
// 킬 태스크 완료 시 전투 결과 반영 및 전리품 획득
|
||||
if (gain) {
|
||||
// 전투 결과에 따라 플레이어 HP 업데이트 + 전투 후 회복
|
||||
// 전투 후 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 =
|
||||
@@ -292,17 +366,16 @@ class ProgressService {
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -319,9 +392,8 @@ class ProgressService {
|
||||
progress = progress.copyWith(currentCombat: combatForReset);
|
||||
}
|
||||
|
||||
// Boss 승리 처리: 시네마틱 트리거
|
||||
// Boss 승리 처리
|
||||
if (progress.pendingActCompletion) {
|
||||
// Act Boss를 처치했으므로 시네마틱 재생
|
||||
final cinematicEntries = pq_logic.interplotCinematic(
|
||||
config,
|
||||
nextState.rng,
|
||||
@@ -332,87 +404,107 @@ class ProgressService {
|
||||
progress = progress.copyWith(
|
||||
currentCombat: null,
|
||||
monstersKilled: progress.monstersKilled + 1,
|
||||
pendingActCompletion: false, // Boss 처치 완료
|
||||
pendingActCompletion: false,
|
||||
);
|
||||
} else {
|
||||
// 일반 전투 종료
|
||||
progress = progress.copyWith(
|
||||
currentCombat: null,
|
||||
monstersKilled: progress.monstersKilled + 1,
|
||||
);
|
||||
}
|
||||
|
||||
nextState = nextState.copyWith(
|
||||
progress: progress,
|
||||
queue: queue,
|
||||
);
|
||||
nextState = nextState.copyWith(progress: progress, queue: queue);
|
||||
|
||||
// 최종 보스 처치 체크
|
||||
if (progress.finalBossState == FinalBossState.fighting) {
|
||||
// 글리치 갓 처치 완료 - 게임 클리어
|
||||
progress = progress.copyWith(finalBossState: FinalBossState.defeated);
|
||||
nextState = nextState.copyWith(progress: progress);
|
||||
|
||||
// completeAct를 호출하여 게임 완료 처리
|
||||
final actResult = completeAct(nextState);
|
||||
nextState = actResult.state;
|
||||
|
||||
return ProgressTickResult(
|
||||
state: nextState,
|
||||
leveledUp: false,
|
||||
completedQuest: false,
|
||||
return (
|
||||
state: actResult.state,
|
||||
progress: actResult.state.progress,
|
||||
queue: actResult.state.queue,
|
||||
earlyReturn: ProgressTickResult(
|
||||
state: actResult.state,
|
||||
completedAct: true,
|
||||
gameComplete: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
state: nextState,
|
||||
progress: progress,
|
||||
queue: queue,
|
||||
earlyReturn: null,
|
||||
);
|
||||
}
|
||||
|
||||
// 시장/판매/구매 태스크 완료 시 처리 (MarketService 사용)
|
||||
final marketService = MarketService(rng: nextState.rng);
|
||||
/// 시장/판매/구매 태스크 완료 처리
|
||||
({
|
||||
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;
|
||||
|
||||
if (taskType == TaskType.buying) {
|
||||
// 장비 구매 완료 (원본 631-634)
|
||||
nextState = marketService.completeBuying(nextState);
|
||||
progress = nextState.progress;
|
||||
} else if (taskType == TaskType.market || taskType == TaskType.sell) {
|
||||
// 시장 도착 또는 판매 완료 (원본 635-649)
|
||||
final sellResult = marketService.processSell(nextState);
|
||||
nextState = sellResult.state;
|
||||
progress = nextState.progress;
|
||||
queue = nextState.queue;
|
||||
|
||||
// 판매 중이면 다른 로직 건너뛰기
|
||||
if (sellResult.continuesSelling) {
|
||||
nextState = _recalculateEncumbrance(
|
||||
nextState.copyWith(progress: progress, queue: queue),
|
||||
);
|
||||
return ProgressTickResult(
|
||||
return (
|
||||
state: nextState,
|
||||
leveledUp: false,
|
||||
completedQuest: false,
|
||||
completedAct: false,
|
||||
progress: progress,
|
||||
queue: queue,
|
||||
earlyReturn: ProgressTickResult(state: nextState),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Gain XP / level up (몬스터 경험치 기반)
|
||||
// 최대 레벨(100) 제한: 100레벨에서는 더 이상 레벨업하지 않음
|
||||
if (gain && nextState.traits.level < 100 && monsterExpReward > 0) {
|
||||
// 종족 경험치 배율 적용 (예: Byte Human +5%, Callback Seraph +3%)
|
||||
return (
|
||||
state: nextState,
|
||||
progress: progress,
|
||||
queue: queue,
|
||||
earlyReturn: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 경험치 획득 및 레벨업 처리
|
||||
({GameState state, ProgressState progress, bool leveledUp}) _handleExpGain(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
int monsterExpReward,
|
||||
) {
|
||||
var nextState = state;
|
||||
var leveledUp = false;
|
||||
|
||||
final race = RaceData.findById(nextState.traits.raceId);
|
||||
final expMultiplier = race?.expMultiplier ?? 1.0;
|
||||
final adjustedExp = (monsterExpReward * expMultiplier).round();
|
||||
final newExpPos = progress.exp.position + adjustedExp;
|
||||
|
||||
// 레벨업 체크 (경험치가 필요량 이상일 때)
|
||||
if (newExpPos >= progress.exp.max) {
|
||||
// 초과 경험치 계산
|
||||
final overflowExp = newExpPos - progress.exp.max;
|
||||
nextState = _levelUp(nextState);
|
||||
leveledUp = true;
|
||||
progress = nextState.progress;
|
||||
|
||||
// 초과 경험치를 다음 레벨에 적용
|
||||
if (overflowExp > 0 && nextState.traits.level < 100) {
|
||||
progress = progress.copyWith(
|
||||
exp: progress.exp.copyWith(position: overflowExp),
|
||||
@@ -423,14 +515,32 @@ class ProgressService {
|
||||
exp: progress.exp.copyWith(position: newExpPos),
|
||||
);
|
||||
}
|
||||
|
||||
return (state: nextState, progress: progress, leveledUp: leveledUp);
|
||||
}
|
||||
|
||||
// Advance quest bar after Act I.
|
||||
/// 퀘스트 진행 처리
|
||||
({
|
||||
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 =
|
||||
gain &&
|
||||
progress.plotStageCount > 1 &&
|
||||
progress.questCount > 0 &&
|
||||
progress.quest.max > 0;
|
||||
|
||||
if (canQuestProgress) {
|
||||
if (progress.quest.position + incrementSeconds >= progress.quest.max) {
|
||||
nextState = completeQuest(nextState);
|
||||
@@ -446,19 +556,31 @@ class ProgressService {
|
||||
}
|
||||
}
|
||||
|
||||
// 플롯(plot) 바가 완료되면 Act Boss 소환
|
||||
// (개선: Boss 처치 → 시네마틱 → Act 전환 순서)
|
||||
return (
|
||||
state: nextState,
|
||||
progress: progress,
|
||||
queue: queue,
|
||||
completed: questDone,
|
||||
);
|
||||
}
|
||||
|
||||
/// 플롯 진행 및 Act Boss 소환 처리
|
||||
ProgressState _handlePlotProgress(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
bool gain,
|
||||
int incrementSeconds,
|
||||
) {
|
||||
if (gain &&
|
||||
progress.plot.max > 0 &&
|
||||
progress.plot.position >= progress.plot.max &&
|
||||
!progress.pendingActCompletion) {
|
||||
// Act Boss 소환 및 플래그 설정
|
||||
final actProgressionService = ActProgressionService(config: config);
|
||||
final actBoss = actProgressionService.createActBoss(nextState);
|
||||
progress = progress.copyWith(
|
||||
plot: progress.plot.copyWith(position: 0), // Plot bar 리셋
|
||||
final actBoss = actProgressionService.createActBoss(state);
|
||||
return progress.copyWith(
|
||||
plot: progress.plot.copyWith(position: 0),
|
||||
currentCombat: actBoss,
|
||||
pendingActCompletion: true, // Boss 처치 대기 플래그
|
||||
pendingActCompletion: true,
|
||||
);
|
||||
} else if (progress.currentTask.type != TaskType.load &&
|
||||
progress.plot.max > 0 &&
|
||||
@@ -467,12 +589,29 @@ class ProgressService {
|
||||
final int newPlotPos = uncappedPlot > progress.plot.max
|
||||
? progress.plot.max
|
||||
: uncappedPlot;
|
||||
progress = progress.copyWith(
|
||||
return progress.copyWith(
|
||||
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);
|
||||
if (dq != null) {
|
||||
progress = dq.progress.copyWith(
|
||||
@@ -480,7 +619,6 @@ class ProgressService {
|
||||
);
|
||||
queue = dq.queue;
|
||||
|
||||
// plot 타입이 dequeue 되면 completeAct 실행 (원본 Main.pas 로직)
|
||||
if (dq.kind == QueueKind.plot) {
|
||||
nextState = nextState.copyWith(progress: progress, queue: queue);
|
||||
final actResult = completeAct(nextState);
|
||||
@@ -491,22 +629,17 @@ class ProgressService {
|
||||
queue = nextState.queue;
|
||||
}
|
||||
} else {
|
||||
// 큐가 비어있으면 새 태스크 생성 (원본 Dequeue 667-684줄)
|
||||
nextState = nextState.copyWith(progress: progress, queue: queue);
|
||||
final newTaskResult = _generateNextTask(nextState);
|
||||
progress = newTaskResult.progress;
|
||||
queue = newTaskResult.queue;
|
||||
}
|
||||
|
||||
nextState = _recalculateEncumbrance(
|
||||
nextState.copyWith(progress: progress, queue: queue),
|
||||
);
|
||||
|
||||
return ProgressTickResult(
|
||||
return (
|
||||
state: nextState,
|
||||
leveledUp: leveledUp,
|
||||
completedQuest: questDone,
|
||||
completedAct: actDone,
|
||||
progress: progress,
|
||||
queue: queue,
|
||||
actDone: actDone,
|
||||
gameComplete: gameComplete,
|
||||
);
|
||||
}
|
||||
@@ -519,68 +652,116 @@ class ProgressService {
|
||||
final queue = state.queue;
|
||||
final oldTaskType = progress.currentTask.type;
|
||||
|
||||
// 1. Encumbrance가 가득 찼으면 시장으로 이동 (원본 667-669줄)
|
||||
if (progress.encumbrance.position >= progress.encumbrance.max &&
|
||||
progress.encumbrance.max > 0) {
|
||||
// 1. Encumbrance 초과 시 시장 이동
|
||||
if (_shouldGoToMarket(progress)) {
|
||||
return _createMarketTask(progress, queue);
|
||||
}
|
||||
|
||||
// 2. 전환 태스크 (buying/heading)
|
||||
if (_needsTransitionTask(oldTaskType)) {
|
||||
return _createTransitionTask(state, progress, queue);
|
||||
}
|
||||
|
||||
// 3. Act Boss 리트라이
|
||||
if (state.progress.pendingActCompletion) {
|
||||
return _createActBossRetryTask(state, progress, queue);
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
progress = taskResult.progress.copyWith(
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.market,
|
||||
),
|
||||
currentCombat: null, // 비전투 태스크이므로 전투 상태 초기화
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: progress, queue: queue);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
// 2. kill/heading/buying 태스크가 아니었으면 heading 또는 buying 태스크 실행
|
||||
// (원본 670-677줄) - buying 완료 후 무한 루프 방지
|
||||
if (oldTaskType != TaskType.kill &&
|
||||
oldTaskType != TaskType.neutral &&
|
||||
oldTaskType != TaskType.buying) {
|
||||
// Gold가 충분하면 장비 구매 (Common 장비 가격 기준)
|
||||
// 실제 구매 가격과 동일한 공식 사용: level * 50
|
||||
/// 전환 태스크 생성 (buying 또는 heading)
|
||||
({ProgressState progress, QueueState queue}) _createTransitionTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final gold = state.inventory.gold;
|
||||
final equipPrice = state.traits.level * 50; // Common 장비 1개 가격
|
||||
final equipPrice = state.traits.level * 50;
|
||||
|
||||
// Gold 충분 시 장비 구매
|
||||
if (gold > equipPrice) {
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskUpgradingHardware(),
|
||||
5 * 1000,
|
||||
);
|
||||
progress = taskResult.progress.copyWith(
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.buying,
|
||||
),
|
||||
currentCombat: null, // 비전투 태스크이므로 전투 상태 초기화
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: progress, queue: queue);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
// Gold가 부족하면 전장으로 이동 (원본 674-676줄)
|
||||
// Gold 부족 시 전장 이동
|
||||
final taskResult = pq_logic.startTask(
|
||||
progress,
|
||||
l10n.taskEnteringDebugZone(),
|
||||
4 * 1000,
|
||||
);
|
||||
progress = taskResult.progress.copyWith(
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.neutral,
|
||||
),
|
||||
currentCombat: null, // 비전투 태스크이므로 전투 상태 초기화
|
||||
currentCombat: null,
|
||||
);
|
||||
return (progress: progress, queue: queue);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
// 3. Act Boss 리트라이 체크
|
||||
// pendingActCompletion이 true면 Act Boss 재소환
|
||||
if (state.progress.pendingActCompletion) {
|
||||
/// 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);
|
||||
@@ -595,12 +776,12 @@ class ProgressService {
|
||||
durationMillis,
|
||||
);
|
||||
|
||||
progress = taskResult.progress.copyWith(
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.kill,
|
||||
monsterBaseName: actBoss.monsterStats.name,
|
||||
monsterPart: '*', // Boss는 WinItem 드랍
|
||||
monsterPart: '*',
|
||||
monsterLevel: actBoss.monsterStats.level,
|
||||
monsterGrade: MonsterGrade.boss,
|
||||
monsterSize: getBossSizeForAct(state.progress.plotStageCount),
|
||||
@@ -608,31 +789,18 @@ class ProgressService {
|
||||
currentCombat: actBoss,
|
||||
);
|
||||
|
||||
return (progress: progress, queue: queue);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
// 4. 최종 보스 전투 체크
|
||||
// finalBossState == fighting이면 Glitch God 스폰
|
||||
// 단, 레벨링 모드 중이면 일반 몬스터로 레벨업 후 재도전
|
||||
if (state.progress.finalBossState == FinalBossState.fighting) {
|
||||
if (state.progress.isInBossLevelingMode) {
|
||||
// 레벨링 모드: 일반 몬스터 전투로 대체 (아래 MonsterTask로 진행)
|
||||
} else {
|
||||
// 레벨링 모드 종료 또는 첫 도전: 보스전 시작
|
||||
// 레벨링 모드가 끝났으면 타이머 초기화
|
||||
if (state.progress.bossLevelingEndTime != null) {
|
||||
progress = progress.copyWith(clearBossLevelingEndTime: true);
|
||||
}
|
||||
final actProgressionService = ActProgressionService(config: config);
|
||||
return actProgressionService.startFinalBossFight(state, progress, queue);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. MonsterTask 실행 (원본 678-684줄)
|
||||
/// 일반 몬스터 전투 태스크 생성
|
||||
({ProgressState progress, QueueState queue}) _createMonsterTask(
|
||||
GameState state,
|
||||
ProgressState progress,
|
||||
QueueState queue,
|
||||
) {
|
||||
final level = state.traits.level;
|
||||
|
||||
// 원본 Main.pas:548-551: 25% 확률로 Quest Monster 사용
|
||||
// fQuest.Caption이 비어있지 않으면 해당 몬스터 데이터 전달
|
||||
// 퀘스트 몬스터 데이터 확인
|
||||
final questMonster = state.progress.currentQuestMonster;
|
||||
final questMonsterData = questMonster?.monsterData;
|
||||
final questLevel = questMonsterData != null
|
||||
@@ -640,6 +808,7 @@ class ProgressService {
|
||||
0
|
||||
: null;
|
||||
|
||||
// 몬스터 생성
|
||||
final monsterResult = pq_logic.monsterTask(
|
||||
config,
|
||||
state.rng,
|
||||
@@ -648,8 +817,7 @@ class ProgressService {
|
||||
questLevel,
|
||||
);
|
||||
|
||||
// 전투용 몬스터 레벨 조정 (밸런스)
|
||||
// Act별 최소 레벨과 플레이어 레벨 중 큰 값을 기준으로 ±3 범위 제한
|
||||
// 몬스터 레벨 조정 (밸런스)
|
||||
final actMinLevel = ActMonsterLevel.forPlotStage(
|
||||
state.progress.plotStageCount,
|
||||
);
|
||||
@@ -658,7 +826,7 @@ class ProgressService {
|
||||
.clamp(math.max(1, baseLevel - 3), baseLevel + 3)
|
||||
.toInt();
|
||||
|
||||
// 전투 스탯 생성 (Phase 12: 몬스터 레벨 기반 페널티 적용)
|
||||
// 전투 스탯 생성
|
||||
final playerCombatStats = CombatStats.fromStats(
|
||||
stats: state.stats,
|
||||
equipment: state.equipment,
|
||||
@@ -673,13 +841,12 @@ class ProgressService {
|
||||
plotStageCount: state.progress.plotStageCount,
|
||||
);
|
||||
|
||||
// 전투 상태 초기화
|
||||
// 전투 상태 및 지속시간
|
||||
final combatState = CombatState.start(
|
||||
playerStats: playerCombatStats,
|
||||
monsterStats: monsterCombatStats,
|
||||
);
|
||||
|
||||
// 태스크 지속시간 계산 (CombatCalculator 기반)
|
||||
final combatCalculator = CombatCalculator(rng: state.rng);
|
||||
final durationMillis = combatCalculator.estimateCombatDurationMs(
|
||||
player: playerCombatStats,
|
||||
@@ -692,14 +859,14 @@ class ProgressService {
|
||||
durationMillis,
|
||||
);
|
||||
|
||||
// 몬스터 사이즈 결정 (Act 기반, Phase 13)
|
||||
// 몬스터 사이즈 결정
|
||||
final monsterSize = getMonsterSizeForAct(
|
||||
plotStageCount: state.progress.plotStageCount,
|
||||
grade: monsterResult.grade,
|
||||
rng: state.rng,
|
||||
);
|
||||
|
||||
progress = taskResult.progress.copyWith(
|
||||
final updatedProgress = taskResult.progress.copyWith(
|
||||
currentTask: TaskInfo(
|
||||
caption: taskResult.caption,
|
||||
type: TaskType.kill,
|
||||
@@ -712,7 +879,7 @@ class ProgressService {
|
||||
currentCombat: combatState,
|
||||
);
|
||||
|
||||
return (progress: progress, queue: queue);
|
||||
return (progress: updatedProgress, queue: queue);
|
||||
}
|
||||
|
||||
/// Advances quest completion, applies reward, and enqueues next quest task.
|
||||
|
||||
96
lib/src/core/model/death_info.dart
Normal file
96
lib/src/core/model/death_info.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
220
lib/src/core/model/equipment_container.dart
Normal file
220
lib/src/core/model/equipment_container.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,16 +17,23 @@ class EquipmentItem with _$EquipmentItem {
|
||||
const factory EquipmentItem({
|
||||
/// 아이템 이름 (예: "Flaming Sword of Doom")
|
||||
required String name,
|
||||
|
||||
/// 장착 슬롯
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
|
||||
required EquipmentSlot slot,
|
||||
|
||||
/// 아이템 레벨
|
||||
required int level,
|
||||
|
||||
/// 무게 (STR 기반 휴대 제한용)
|
||||
required int weight,
|
||||
|
||||
/// 아이템 스탯 보정치
|
||||
required ItemStats stats,
|
||||
|
||||
/// 희귀도
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
|
||||
required ItemRarity rarity,
|
||||
}) = _EquipmentItem;
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import 'dart:collection';
|
||||
/// 게임 상태 모듈 (Game State Module)
|
||||
///
|
||||
/// 하위 파일들을 re-export하여 기존 import 호환성 유지
|
||||
library;
|
||||
|
||||
import 'package:asciineverdie/src/core/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_state.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';
|
||||
import 'package:asciineverdie/src/core/model/monster_grade.dart';
|
||||
export 'death_info.dart';
|
||||
export 'equipment_container.dart';
|
||||
export 'inventory.dart';
|
||||
export 'progress_state.dart';
|
||||
export 'queue_state.dart';
|
||||
export 'skill_book.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/skill.dart';
|
||||
import 'package:asciineverdie/src/core/model/skill_slots.dart';
|
||||
import 'package:asciineverdie/src/core/model/progress_state.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';
|
||||
|
||||
/// Minimal skeletal state to mirror Progress Quest structures.
|
||||
/// 게임 전체 상태 (Game State)
|
||||
///
|
||||
/// Logic will be ported faithfully from the Delphi source; this file only
|
||||
/// defines containers and helpers for deterministic RNG.
|
||||
/// Progress Quest 구조를 미러링하는 최소 스켈레톤 상태.
|
||||
/// 로직은 Delphi 소스에서 충실하게 포팅됨.
|
||||
class GameState {
|
||||
GameState({
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
28
lib/src/core/model/inventory.dart
Normal file
28
lib/src/core/model/inventory.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ class MonetizationState with _$MonetizationState {
|
||||
@Default(1) int undoRemaining,
|
||||
|
||||
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
|
||||
List<Stats>? rollHistory,
|
||||
|
||||
@@ -33,6 +34,7 @@ class MonetizationState with _$MonetizationState {
|
||||
int? speedBoostEndMs,
|
||||
|
||||
/// 마지막 플레이 시각 (복귀 보상 계산용)
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
|
||||
DateTime? lastPlayTime,
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 몬스터 등급 (드랍 품질 및 UI 표시에 영향)
|
||||
enum MonsterGrade {
|
||||
/// 일반 몬스터 (85% 확률)
|
||||
@@ -48,13 +46,13 @@ extension MonsterGradeExtension on MonsterGrade {
|
||||
MonsterGrade.boss => 2.5, // +150% 경험치
|
||||
};
|
||||
|
||||
/// UI 표시용 색상
|
||||
/// - Normal: 기본 텍스트 색상 (null 반환 → 기본 스타일 사용)
|
||||
/// UI 표시용 색상 코드 (hex)
|
||||
/// - Normal: null (기본 스타일 사용)
|
||||
/// - Elite: 파란색 (#7AA2F7)
|
||||
/// - Boss: 금색 (#E0AF68)
|
||||
Color? get displayColor => switch (this) {
|
||||
int? get displayColorCode => switch (this) {
|
||||
MonsterGrade.normal => null,
|
||||
MonsterGrade.elite => const Color(0xFF7AA2F7), // MP 파랑
|
||||
MonsterGrade.boss => const Color(0xFFE0AF68), // 골드
|
||||
MonsterGrade.elite => 0xFF7AA2F7, // MP 파랑
|
||||
MonsterGrade.boss => 0xFFE0AF68, // 골드
|
||||
};
|
||||
}
|
||||
|
||||
192
lib/src/core/model/progress_state.dart
Normal file
192
lib/src/core/model/progress_state.dart
Normal 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!;
|
||||
}
|
||||
}
|
||||
37
lib/src/core/model/queue_state.dart
Normal file
37
lib/src/core/model/queue_state.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
26
lib/src/core/model/skill_book.dart
Normal file
26
lib/src/core/model/skill_book.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
106
lib/src/core/model/skill_system_state.dart
Normal file
106
lib/src/core/model/skill_system_state.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
75
lib/src/core/model/stats.dart
Normal file
75
lib/src/core/model/stats.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
67
lib/src/core/model/task_info.dart
Normal file
67
lib/src/core/model/task_info.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
66
lib/src/core/model/traits.dart
Normal file
66
lib/src/core/model/traits.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
||||
|
||||
/// 알림 타입 (Notification Type)
|
||||
enum NotificationType {
|
||||
levelUp, // 레벨업
|
||||
@@ -62,8 +64,8 @@ class NotificationService {
|
||||
show(
|
||||
GameNotification(
|
||||
type: NotificationType.levelUp,
|
||||
title: 'LEVEL UP!',
|
||||
subtitle: 'Level $newLevel',
|
||||
title: game_l10n.notifyLevelUp,
|
||||
subtitle: game_l10n.notifyLevel(newLevel),
|
||||
data: {'level': newLevel},
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
@@ -75,7 +77,7 @@ class NotificationService {
|
||||
show(
|
||||
GameNotification(
|
||||
type: NotificationType.questComplete,
|
||||
title: 'QUEST COMPLETE!',
|
||||
title: game_l10n.notifyQuestComplete,
|
||||
subtitle: questName,
|
||||
data: {'quest': questName},
|
||||
duration: const Duration(seconds: 2),
|
||||
@@ -87,8 +89,8 @@ class NotificationService {
|
||||
/// actNumber: 0=프롤로그, 1=Act I, 2=Act II, ...
|
||||
void showActComplete(int actNumber) {
|
||||
final title = actNumber == 0
|
||||
? 'PROLOGUE COMPLETE!'
|
||||
: 'ACT $actNumber COMPLETE!';
|
||||
? game_l10n.notifyPrologueComplete
|
||||
: game_l10n.notifyActComplete(actNumber);
|
||||
show(
|
||||
GameNotification(
|
||||
type: NotificationType.actComplete,
|
||||
@@ -103,7 +105,7 @@ class NotificationService {
|
||||
show(
|
||||
GameNotification(
|
||||
type: NotificationType.newSpell,
|
||||
title: 'NEW SPELL!',
|
||||
title: game_l10n.notifyNewSpell,
|
||||
subtitle: spellName,
|
||||
data: {'spell': spellName},
|
||||
duration: const Duration(seconds: 2),
|
||||
@@ -116,7 +118,7 @@ class NotificationService {
|
||||
show(
|
||||
GameNotification(
|
||||
type: NotificationType.newEquipment,
|
||||
title: 'NEW EQUIPMENT!',
|
||||
title: game_l10n.notifyNewEquipment,
|
||||
subtitle: equipmentName,
|
||||
data: {'equipment': equipmentName, 'slot': slot},
|
||||
duration: const Duration(seconds: 2),
|
||||
@@ -129,7 +131,7 @@ class NotificationService {
|
||||
show(
|
||||
GameNotification(
|
||||
type: NotificationType.bossDefeat,
|
||||
title: 'BOSS DEFEATED!',
|
||||
title: game_l10n.notifyBossDefeated,
|
||||
subtitle: bossName,
|
||||
data: {'boss': bossName},
|
||||
duration: const Duration(seconds: 3),
|
||||
|
||||
@@ -217,9 +217,9 @@ class _AnimationPanel extends StatelessWidget {
|
||||
children: [
|
||||
const Icon(Icons.auto_awesome, color: RetroColors.gold, size: 18),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'ASCII NEVER DIE',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context).appTitle.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: RetroColors.gold,
|
||||
@@ -272,7 +272,7 @@ class _ActionButtons extends StatelessWidget {
|
||||
final l10n = L10n.of(context);
|
||||
|
||||
return RetroPanel(
|
||||
title: 'MENU',
|
||||
title: L10n.of(context).menuTitle,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
||||
@@ -551,11 +551,21 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
// 로케일 변경 시 전체 위젯 트리 강제 리빌드를 위한 Key
|
||||
final localeKey = ValueKey(game_l10n.currentGameLocale);
|
||||
|
||||
// 캐로셀 레이아웃 사용 여부 확인
|
||||
if (_shouldUseCarouselLayout(context)) {
|
||||
return _buildMobileLayout(context, state, localeKey);
|
||||
}
|
||||
|
||||
return _buildDesktopLayout(context, state, localeKey);
|
||||
}
|
||||
|
||||
/// 모바일 캐로셀 레이아웃
|
||||
Widget _buildMobileLayout(
|
||||
BuildContext context,
|
||||
GameState state,
|
||||
ValueKey<String> localeKey,
|
||||
) {
|
||||
return NotificationOverlay(
|
||||
key: localeKey,
|
||||
notificationService: _notificationService,
|
||||
@@ -585,7 +595,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
widget.controller.loop?.setSpeed(speed);
|
||||
setState(() {});
|
||||
},
|
||||
// 특수 애니메이션 중에는 일시정지 상태로 표시하지 않음
|
||||
isPaused:
|
||||
!widget.controller.isRunning && _specialAnimation == null,
|
||||
onPauseToggle: () async {
|
||||
@@ -605,13 +614,9 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
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(
|
||||
@@ -625,16 +630,12 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
}
|
||||
},
|
||||
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) {
|
||||
@@ -645,14 +646,11 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
_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(),
|
||||
onCheatQuest: () => widget.controller.loop?.cheatCompleteQuest(),
|
||||
onCheatPlot: () => widget.controller.loop?.cheatCompletePlot(),
|
||||
onCreateTestCharacter: () async {
|
||||
final navigator = Navigator.of(context);
|
||||
@@ -661,7 +659,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
},
|
||||
// 수익화 버프 (자동부활, 광고배속)
|
||||
autoReviveEndMs: widget.controller.monetization.autoReviveEndMs,
|
||||
speedBoostEndMs: widget.controller.monetization.speedBoostEndMs,
|
||||
isPaidUser: widget.controller.monetization.isPaidUser,
|
||||
@@ -670,31 +667,19 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
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,
|
||||
),
|
||||
..._buildOverlays(state),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 기존 데스크톱 레이아웃 (레트로 스타일)
|
||||
/// 데스크톱 3패널 레이아웃
|
||||
Widget _buildDesktopLayout(
|
||||
BuildContext context,
|
||||
GameState state,
|
||||
ValueKey<String> localeKey,
|
||||
) {
|
||||
return NotificationOverlay(
|
||||
key: localeKey,
|
||||
notificationService: _notificationService,
|
||||
@@ -710,13 +695,27 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
}
|
||||
}
|
||||
},
|
||||
// 웹/데스크톱 키보드 단축키 지원
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: (node, event) => _handleKeyboardShortcut(event, context),
|
||||
child: Scaffold(
|
||||
backgroundColor: RetroColors.deepBrown,
|
||||
appBar: AppBar(
|
||||
appBar: _buildDesktopAppBar(context, state),
|
||||
body: Stack(
|
||||
children: [
|
||||
_buildDesktopMainContent(state),
|
||||
..._buildOverlays(state),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 데스크톱 앱바
|
||||
PreferredSizeWidget _buildDesktopAppBar(BuildContext context, GameState state) {
|
||||
return AppBar(
|
||||
backgroundColor: RetroColors.darkBrown,
|
||||
title: Text(
|
||||
L10n.of(context).progressQuestTitle(state.traits.name),
|
||||
@@ -727,65 +726,54 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
// 치트 버튼 (디버그용)
|
||||
if (widget.controller.cheatsEnabled) ...[
|
||||
IconButton(
|
||||
icon: const Text('L+1'),
|
||||
tooltip: L10n.of(context).levelUp,
|
||||
onPressed: () =>
|
||||
widget.controller.loop?.cheatCompleteTask(),
|
||||
onPressed: () => widget.controller.loop?.cheatCompleteTask(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Text('Q!'),
|
||||
tooltip: L10n.of(context).completeQuest,
|
||||
onPressed: () =>
|
||||
widget.controller.loop?.cheatCompleteQuest(),
|
||||
onPressed: () => widget.controller.loop?.cheatCompleteQuest(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Text('P!'),
|
||||
tooltip: L10n.of(context).completePlot,
|
||||
onPressed: () =>
|
||||
widget.controller.loop?.cheatCompletePlot(),
|
||||
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(
|
||||
);
|
||||
}
|
||||
|
||||
/// 데스크톱 메인 컨텐츠 (3패널)
|
||||
Widget _buildDesktopMainContent(GameState state) {
|
||||
return Column(
|
||||
children: [
|
||||
// 메인 게임 UI
|
||||
Column(
|
||||
children: [
|
||||
// 상단: ASCII 애니메이션 + Task Progress (Phase 7: 고정 4색 팔레트)
|
||||
TaskProgressPanel(
|
||||
progress: state.progress,
|
||||
speedMultiplier:
|
||||
widget.controller.loop?.speedMultiplier ?? 1,
|
||||
speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1,
|
||||
onSpeedCycle: () {
|
||||
widget.controller.loop?.cycleSpeed();
|
||||
setState(() {});
|
||||
},
|
||||
// 특수 애니메이션 중에는 일시정지 상태로 표시하지 않음
|
||||
isPaused:
|
||||
!widget.controller.isRunning &&
|
||||
_specialAnimation == null,
|
||||
isPaused: !widget.controller.isRunning && _specialAnimation == null,
|
||||
onPauseToggle: () async {
|
||||
await widget.controller.togglePause();
|
||||
setState(() {});
|
||||
@@ -801,27 +789,23 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 사망 오버레이
|
||||
/// 공통 오버레이 (사망, 승리)
|
||||
List<Widget> _buildOverlays(GameState state) {
|
||||
return [
|
||||
if (state.isDead && state.deathInfo != null)
|
||||
DeathOverlay(
|
||||
deathInfo: state.deathInfo!,
|
||||
@@ -830,7 +814,6 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
onAdRevive: _handleAdRevive,
|
||||
isPaidUser: IAPService.instance.isAdRemovalPurchased,
|
||||
),
|
||||
// 승리 오버레이 (게임 클리어)
|
||||
if (widget.controller.isComplete)
|
||||
VictoryOverlay(
|
||||
traits: state.traits,
|
||||
@@ -839,12 +822,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
elapsedMs: state.skillSystem.elapsedMs,
|
||||
onComplete: _handleVictoryComplete,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
];
|
||||
}
|
||||
|
||||
/// 키보드 단축키 핸들러 (웹/데스크톱)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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/widgets/carousel_nav_bar.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/menu/retro_menu_widgets.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
|
||||
|
||||
@@ -177,7 +181,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
void _showLanguageDialog(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => _RetroSelectDialog(
|
||||
builder: (context) => RetroSelectDialog(
|
||||
title: l10n.menuLanguage.toUpperCase(),
|
||||
children: [
|
||||
_buildLanguageOption(context, 'en', l10n.languageEnglish, '🇺🇸'),
|
||||
@@ -195,7 +199,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
String flag,
|
||||
) {
|
||||
final isSelected = l10n.currentGameLocale == locale;
|
||||
return _RetroOptionItem(
|
||||
return RetroOptionItem(
|
||||
label: label.toUpperCase(),
|
||||
prefix: flag,
|
||||
isSelected: isSelected,
|
||||
@@ -224,7 +228,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => _RetroSoundDialog(
|
||||
builder: (context, setDialogState) => RetroSoundDialog(
|
||||
bgmVolume: bgmVolume,
|
||||
sfxVolume: sfxVolume,
|
||||
onBgmChanged: (double value) {
|
||||
@@ -244,7 +248,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
void _showDeleteConfirmDialog(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => _RetroConfirmDialog(
|
||||
builder: (context) => RetroConfirmDialog(
|
||||
title: l10n.confirmDeleteTitle.toUpperCase(),
|
||||
message: l10n.confirmDeleteMessage,
|
||||
confirmText: l10n.buttonConfirm.toUpperCase(),
|
||||
@@ -262,14 +266,11 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
Future<void> _showTestCharacterDialog(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => _RetroConfirmDialog(
|
||||
title: 'CREATE TEST CHARACTER?',
|
||||
message: '현재 캐릭터가 레벨 100으로 변환되어\n'
|
||||
'명예의 전당에 등록됩니다.\n\n'
|
||||
'⚠️ 현재 세이브 파일이 삭제됩니다.\n'
|
||||
'이 작업은 되돌릴 수 없습니다.',
|
||||
confirmText: 'CREATE',
|
||||
cancelText: 'CANCEL',
|
||||
builder: (context) => RetroConfirmDialog(
|
||||
title: L10n.of(context).debugCreateTestCharacterTitle,
|
||||
message: L10n.of(context).debugCreateTestCharacterMessage,
|
||||
confirmText: L10n.of(context).createButton,
|
||||
cancelText: L10n.of(context).cancel.toUpperCase(),
|
||||
onConfirm: () => Navigator.of(context).pop(true),
|
||||
onCancel: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
@@ -326,7 +327,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
Icon(Icons.settings, color: gold, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'OPTIONS',
|
||||
L10n.of(context).optionsTitle,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
@@ -350,10 +351,10 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// === 게임 제어 ===
|
||||
const _RetroMenuSection(title: 'CONTROL'),
|
||||
RetroMenuSection(title: L10n.of(context).controlSection),
|
||||
const SizedBox(height: 8),
|
||||
// 일시정지/재개
|
||||
_RetroMenuItem(
|
||||
RetroMenuItem(
|
||||
icon: widget.isPaused ? Icons.play_arrow : Icons.pause,
|
||||
iconColor: widget.isPaused
|
||||
? RetroColors.expOf(context)
|
||||
@@ -368,7 +369,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 속도 조절
|
||||
_RetroMenuItem(
|
||||
RetroMenuItem(
|
||||
icon: Icons.speed,
|
||||
iconColor: gold,
|
||||
label: l10n.menuSpeed.toUpperCase(),
|
||||
@@ -377,10 +378,10 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// === 정보 ===
|
||||
const _RetroMenuSection(title: 'INFO'),
|
||||
RetroMenuSection(title: L10n.of(context).infoSection),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.onShowStatistics != null)
|
||||
_RetroMenuItem(
|
||||
RetroMenuItem(
|
||||
icon: Icons.bar_chart,
|
||||
iconColor: RetroColors.mpOf(context),
|
||||
label: l10n.uiStatistics.toUpperCase(),
|
||||
@@ -391,7 +392,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
),
|
||||
if (widget.onShowHelp != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_RetroMenuItem(
|
||||
RetroMenuItem(
|
||||
icon: Icons.help_outline,
|
||||
iconColor: RetroColors.expOf(context),
|
||||
label: l10n.uiHelp.toUpperCase(),
|
||||
@@ -404,9 +405,9 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// === 설정 ===
|
||||
const _RetroMenuSection(title: 'SETTINGS'),
|
||||
RetroMenuSection(title: L10n.of(context).settingsSection),
|
||||
const SizedBox(height: 8),
|
||||
_RetroMenuItem(
|
||||
RetroMenuItem(
|
||||
icon: Icons.language,
|
||||
iconColor: RetroColors.mpOf(context),
|
||||
label: l10n.menuLanguage.toUpperCase(),
|
||||
@@ -419,7 +420,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
if (widget.onBgmVolumeChange != null ||
|
||||
widget.onSfxVolumeChange != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_RetroMenuItem(
|
||||
RetroMenuItem(
|
||||
icon: widget.bgmVolume == 0 && widget.sfxVolume == 0
|
||||
? Icons.volume_off
|
||||
: Icons.volume_up,
|
||||
@@ -435,9 +436,9 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// === 저장/종료 ===
|
||||
const _RetroMenuSection(title: 'SAVE / EXIT'),
|
||||
RetroMenuSection(title: L10n.of(context).saveExitSection),
|
||||
const SizedBox(height: 8),
|
||||
_RetroMenuItem(
|
||||
RetroMenuItem(
|
||||
icon: Icons.save,
|
||||
iconColor: RetroColors.mpOf(context),
|
||||
label: l10n.menuSave.toUpperCase(),
|
||||
@@ -450,7 +451,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RetroMenuItem(
|
||||
RetroMenuItem(
|
||||
icon: Icons.refresh,
|
||||
iconColor: RetroColors.warningOf(context),
|
||||
label: l10n.menuNewGame.toUpperCase(),
|
||||
@@ -461,7 +462,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RetroMenuItem(
|
||||
RetroMenuItem(
|
||||
icon: Icons.exit_to_app,
|
||||
iconColor: RetroColors.hpOf(context),
|
||||
label: localizations.exitGame.toUpperCase(),
|
||||
@@ -474,38 +475,38 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
// === 치트 섹션 (디버그 모드에서만) ===
|
||||
if (widget.cheatsEnabled) ...[
|
||||
const SizedBox(height: 16),
|
||||
_RetroMenuSection(
|
||||
title: 'DEBUG CHEATS',
|
||||
RetroMenuSection(
|
||||
title: L10n.of(context).debugCheatsTitle,
|
||||
color: RetroColors.hpOf(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RetroMenuItem(
|
||||
RetroMenuItem(
|
||||
icon: Icons.fast_forward,
|
||||
iconColor: RetroColors.hpOf(context),
|
||||
label: 'SKIP TASK (L+1)',
|
||||
subtitle: '태스크 즉시 완료',
|
||||
label: L10n.of(context).debugSkipTask,
|
||||
subtitle: L10n.of(context).debugSkipTaskDesc,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onCheatTask?.call();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RetroMenuItem(
|
||||
RetroMenuItem(
|
||||
icon: Icons.skip_next,
|
||||
iconColor: RetroColors.hpOf(context),
|
||||
label: 'SKIP QUEST (Q!)',
|
||||
subtitle: '퀘스트 즉시 완료',
|
||||
label: L10n.of(context).debugSkipQuest,
|
||||
subtitle: L10n.of(context).debugSkipQuestDesc,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onCheatQuest?.call();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RetroMenuItem(
|
||||
RetroMenuItem(
|
||||
icon: Icons.double_arrow,
|
||||
iconColor: RetroColors.hpOf(context),
|
||||
label: 'SKIP ACT (P!)',
|
||||
subtitle: '액트 즉시 완료',
|
||||
label: L10n.of(context).debugSkipAct,
|
||||
subtitle: L10n.of(context).debugSkipActDesc,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onCheatPlot?.call();
|
||||
@@ -516,16 +517,16 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
// === 디버그 도구 섹션 ===
|
||||
if (kDebugMode && widget.onCreateTestCharacter != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_RetroMenuSection(
|
||||
title: 'DEBUG TOOLS',
|
||||
RetroMenuSection(
|
||||
title: L10n.of(context).debugToolsTitle,
|
||||
color: RetroColors.warningOf(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_RetroMenuItem(
|
||||
RetroMenuItem(
|
||||
icon: Icons.science,
|
||||
iconColor: RetroColors.warningOf(context),
|
||||
label: 'CREATE TEST CHARACTER',
|
||||
subtitle: '레벨 100 캐릭터를 명예의 전당에 등록',
|
||||
label: L10n.of(context).debugCreateTestCharacter,
|
||||
subtitle: L10n.of(context).debugCreateTestCharacterDesc,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showTestCharacterDialog(context);
|
||||
@@ -553,7 +554,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
||||
final isSpeedBoostActive = widget.isSpeedBoostActive;
|
||||
final adSpeed = widget.adSpeedMultiplier;
|
||||
|
||||
return _RetroSpeedChip(
|
||||
return RetroSpeedChip(
|
||||
speed: adSpeed,
|
||||
isSelected: isSpeedBoostActive,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
151
lib/src/features/game/managers/game_statistics_manager.dart
Normal file
151
lib/src/features/game/managers/game_statistics_manager.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
135
lib/src/features/game/managers/hall_of_fame_manager.dart
Normal file
135
lib/src/features/game/managers/hall_of_fame_manager.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
156
lib/src/features/game/managers/resurrection_manager.dart
Normal file
156
lib/src/features/game/managers/resurrection_manager.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
196
lib/src/features/game/managers/return_rewards_manager.dart
Normal file
196
lib/src/features/game/managers/return_rewards_manager.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
211
lib/src/features/game/managers/speed_boost_manager.dart
Normal file
211
lib/src/features/game/managers/speed_boost_manager.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -785,7 +785,9 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
child: AsciiDisintegrateWidget(
|
||||
characterLines: _deathAnimationMonsterLines!,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
textColor: widget.monsterGrade?.displayColor,
|
||||
textColor: widget.monsterGrade?.displayColorCode != null
|
||||
? Color(widget.monsterGrade!.displayColorCode!)
|
||||
: null,
|
||||
onComplete: _onDeathAnimationComplete,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
115
lib/src/features/game/widgets/dialogs/retro_select_dialog.dart
Normal file
115
lib/src/features/game/widgets/dialogs/retro_select_dialog.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
146
lib/src/features/game/widgets/dialogs/retro_sound_dialog.dart
Normal file
146
lib/src/features/game/widgets/dialogs/retro_sound_dialog.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -756,9 +756,10 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
final gradePrefix = (isKillTask && grade != null)
|
||||
? grade.displayPrefix
|
||||
: '';
|
||||
final gradeColor = (isKillTask && grade != null)
|
||||
? grade.displayColor
|
||||
final gradeColorCode = (isKillTask && grade != null)
|
||||
? grade.displayColorCode
|
||||
: null;
|
||||
final gradeColor = gradeColorCode != null ? Color(gradeColorCode) : null;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
|
||||
204
lib/src/features/game/widgets/menu/retro_menu_widgets.dart
Normal file
204
lib/src/features/game/widgets/menu/retro_menu_widgets.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -169,9 +169,10 @@ class TaskProgressPanel extends StatelessWidget {
|
||||
final gradePrefix = (isKillTask && grade != null)
|
||||
? grade.displayPrefix
|
||||
: '';
|
||||
final gradeColor = (isKillTask && grade != null)
|
||||
? grade.displayColor
|
||||
final gradeColorCode = (isKillTask && grade != null)
|
||||
? grade.displayColorCode
|
||||
: null;
|
||||
final gradeColor = gradeColorCode != null ? Color(gradeColorCode) : null;
|
||||
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show FilteringTextInputFormatter;
|
||||
|
||||
import 'package:asciineverdie/data/class_data.dart';
|
||||
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/race_traits.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/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_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/widgets/retro_widgets.dart';
|
||||
|
||||
@@ -69,9 +71,6 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
// 굴리기/되돌리기 서비스
|
||||
final CharacterRollService _rollService = CharacterRollService.instance;
|
||||
|
||||
// 서비스 초기화 완료 여부
|
||||
bool _isServiceInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -99,11 +98,6 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
/// 서비스 초기화
|
||||
Future<void> _initializeService() async {
|
||||
await _rollService.initialize();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isServiceInitialized = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -227,9 +221,9 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: RetroColors.panelBg,
|
||||
title: const Text(
|
||||
'RECHARGE ROLLS',
|
||||
style: TextStyle(
|
||||
title: Text(
|
||||
L10n.of(context).rechargeRollsTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: RetroColors.gold,
|
||||
@@ -237,8 +231,8 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
),
|
||||
content: Text(
|
||||
isPaidUser
|
||||
? 'Recharge 5 rolls for free?'
|
||||
: 'Watch an ad to recharge 5 rolls?',
|
||||
? L10n.of(context).rechargeRollsFree
|
||||
: L10n.of(context).rechargeRollsAd,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
@@ -248,9 +242,9 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text(
|
||||
'CANCEL',
|
||||
style: TextStyle(
|
||||
child: Text(
|
||||
L10n.of(context).cancel.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
color: RetroColors.textDisabled,
|
||||
@@ -266,9 +260,9 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
const Icon(Icons.play_circle, size: 14, color: RetroColors.gold),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
const Text(
|
||||
'RECHARGE',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
L10n.of(context).rechargeButton,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 11,
|
||||
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! 버튼 클릭 - 캐릭터 생성 완료
|
||||
/// 원본 Main.pas:1371-1388 RollCharacter 로직
|
||||
void _onSold() {
|
||||
@@ -438,16 +416,32 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 이름 입력 섹션
|
||||
_buildNameSection(),
|
||||
NameInputSection(
|
||||
controller: _nameController,
|
||||
onGenerateName: _onGenerateName,
|
||||
),
|
||||
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),
|
||||
|
||||
// 종족 미리보기 (Phase 5: 종족별 캐릭터 애니메이션)
|
||||
RetroPanel(
|
||||
title: 'PREVIEW',
|
||||
title: L10n.of(context).previewTitle,
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Center(
|
||||
child: RacePreview(raceId: _races[_selectedRaceIndex].raceId),
|
||||
@@ -459,9 +453,25 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _buildRaceSection()),
|
||||
Expanded(
|
||||
child: RaceSelectionSection(
|
||||
races: _races,
|
||||
selectedIndex: _selectedRaceIndex,
|
||||
scrollController: _raceScrollController,
|
||||
onSelected: (index) =>
|
||||
setState(() => _selectedRaceIndex = index),
|
||||
),
|
||||
),
|
||||
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),
|
||||
@@ -476,13 +486,21 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
// 디버그 전용: 치트 모드 토글 (100x 터보 배속)
|
||||
if (kDebugMode) ...[
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
_buildDebugCheatToggle(context),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 디버그 치트 토글 위젯
|
||||
Widget _buildDebugCheatToggle(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _cheatsEnabled = !_cheatsEnabled),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _cheatsEnabled
|
||||
? RetroColors.hpRed.withValues(alpha: 0.3)
|
||||
@@ -498,9 +516,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
_cheatsEnabled
|
||||
? Icons.bug_report
|
||||
: Icons.bug_report_outlined,
|
||||
_cheatsEnabled ? Icons.bug_report : Icons.bug_report_outlined,
|
||||
size: 16,
|
||||
color: _cheatsEnabled
|
||||
? RetroColors.hpRed
|
||||
@@ -509,7 +525,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'DEBUG: TURBO (20x)',
|
||||
L10n.of(context).debugTurbo,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
@@ -523,502 +539,6 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNameSection() {
|
||||
final l10n = L10n.of(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(
|
||||
onTap: canUndo ? _onUnroll : 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 굴리기 버튼
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
302
lib/src/features/new_character/widgets/stats_section.dart
Normal file
302
lib/src/features/new_character/widgets/stats_section.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
||||
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/engine/debug_settings_service.dart';
|
||||
import 'package:asciineverdie/src/core/storage/settings_repository.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
@@ -145,7 +146,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// 디버그 섹션 (디버그 모드에서만 표시)
|
||||
if (kDebugMode) ...[
|
||||
const SizedBox(height: 16),
|
||||
const _RetroSectionTitle(title: 'DEBUG'),
|
||||
_RetroSectionTitle(title: L10n.of(context).debugTitle),
|
||||
const SizedBox(height: 8),
|
||||
_buildDebugSection(context),
|
||||
],
|
||||
@@ -308,7 +309,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'DEVELOPER TOOLS',
|
||||
L10n.of(context).debugDeveloperTools,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
@@ -322,8 +323,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// IAP 시뮬레이션 토글
|
||||
_RetroDebugToggle(
|
||||
icon: Icons.shopping_cart,
|
||||
label: 'IAP PURCHASED',
|
||||
description: 'ON: 유료 유저로 동작 (광고 제거)',
|
||||
label: L10n.of(context).debugIapPurchased,
|
||||
description: L10n.of(context).debugIapPurchasedDesc,
|
||||
value: _debugIapSimulated,
|
||||
onChanged: (value) async {
|
||||
await DebugSettingsService.instance.setIapSimulated(value);
|
||||
@@ -346,7 +347,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// 테스트 캐릭터 생성
|
||||
if (widget.onCreateTestCharacter != null) ...[
|
||||
Text(
|
||||
'현재 캐릭터를 레벨 100으로 수정하여\n명예의 전당에 등록합니다.',
|
||||
L10n.of(context).debugTestCharacterDesc,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
@@ -358,7 +359,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: RetroTextButton(
|
||||
text: 'CREATE TEST CHARACTER',
|
||||
text: L10n.of(context).debugCreateTestCharacter,
|
||||
icon: Icons.science,
|
||||
onPressed: _handleCreateTestCharacter,
|
||||
),
|
||||
@@ -388,7 +389,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'OFFLINE HOURS',
|
||||
L10n.of(context).debugOfflineHours,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
@@ -397,7 +398,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'복귀 보상 테스트 (재시작 시 적용)',
|
||||
L10n.of(context).debugOfflineHoursDesc,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
@@ -435,14 +436,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => _RetroConfirmDialog(
|
||||
title: 'CREATE TEST CHARACTER?',
|
||||
message:
|
||||
'현재 캐릭터가 레벨 100으로 변환되어\n'
|
||||
'명예의 전당에 등록됩니다.\n\n'
|
||||
'⚠️ 현재 세이브 파일이 삭제됩니다.\n'
|
||||
'이 작업은 되돌릴 수 없습니다.',
|
||||
confirmText: 'CREATE',
|
||||
cancelText: 'CANCEL',
|
||||
title: L10n.of(context).debugCreateTestCharacterTitle,
|
||||
message: L10n.of(context).debugCreateTestCharacterMessage,
|
||||
confirmText: L10n.of(context).createButton,
|
||||
cancelText: L10n.of(context).cancel.toUpperCase(),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_theme_constants.dart';
|
||||
|
||||
/// 패널 헤더 변형
|
||||
enum PanelHeaderVariant {
|
||||
|
||||
Reference in New Issue
Block a user