Compare commits

...

71 Commits

Author SHA1 Message Date
JiWoong Sul
fc15198c57 chore(android): Play Core ProGuard 경고 억제 규칙 추가
- deferred components 관련 dontwarn 규칙 추가
2026-02-23 16:47:51 +09:00
JiWoong Sul
c56e76b176 test: 스킬 서비스 테스트 업데이트
- import 경로 변경 반영
2026-02-23 15:49:50 +09:00
JiWoong Sul
dadd25837d docs: CLAUDE.md 및 감사 보고서 업데이트
- CLAUDE.md 아키텍처 문서 최신화
- 감사 보고서 수정 사항 반영
2026-02-23 15:49:46 +09:00
JiWoong Sul
e13e8032d9 chore(build): 빌드 설정 업데이트
- Android: proguard 규칙 추가, build.gradle 업데이트
- iOS: 권한 설정 및 프로젝트 구성 업데이트
- macOS: 앱 정보 및 entitlements 업데이트
2026-02-23 15:49:43 +09:00
JiWoong Sul
864a866039 refactor(ui): 위젯 분리 및 화면 개선
- game_play_screen에서 desktop 패널 위젯 분리
- death_overlay에서 death_buttons, death_combat_log 분리
- mobile_carousel_layout에서 mobile_options_menu 분리
- 아레나 위젯 개선 (arena_hp_bar, result_panel 등)
- settings_screen에서 retro_settings_widgets 분리
- 기타 위젯 리팩토링 및 import 경로 업데이트
2026-02-23 15:49:38 +09:00
JiWoong Sul
6ddbf23816 feat(app): 테마 시스템 및 스플래시 화면 추가
- AppTheme 클래스 분리 (app_theme.dart)
- 스플래시 화면 추가 (splash_screen.dart)
- app.dart 경량화
2026-02-23 15:49:32 +09:00
JiWoong Sul
1a8858a3b1 feat(l10n): 다국어 리소스 확장
- 아레나, 통계, 설정 등 신규 번역 키 추가
- 한국어, 영어, 일본어 리소스 업데이트
- 생성된 localizations 파일 반영
2026-02-23 15:49:28 +09:00
JiWoong Sul
faaa5af54e refactor(model): 통계 모델 분리
- game_statistics에서 cumulative_statistics, session_statistics 분리
- task_info import 경로 업데이트
2026-02-23 15:49:23 +09:00
JiWoong Sul
68284323c8 refactor(engine): 엔진 서비스 분리 및 리팩토링
- progress_service에서 death_handler, loot_handler, task_generator 분리
- combat_tick_service에서 player_attack_processor 분리
- arena_service에서 arena_combat_simulator 분리
- skill_service에서 skill_auto_selector 분리
2026-02-23 15:49:20 +09:00
JiWoong Sul
8f351df0b6 refactor(shared): animation, l10n, theme 모듈을 core에서 shared로 이동
- core/animation → shared/animation
- core/l10n → shared/l10n
- core/constants/ascii_colors → shared/theme/ascii_colors
- import 경로 업데이트
2026-02-23 15:49:14 +09:00
JiWoong Sul
8fcb7bf2b7 docs(audit): 감사 보고서 수정 완료 항목 반영
- 번들 ID 수정 완료 표시 (S1, S2, R1, R2, R4, B5)
- 보안 이슈 소유자 확인 (개인 비공개 저장소)
- CRITICAL 이슈 20건 → 15건으로 감소
2026-02-15 14:46:12 +09:00
JiWoong Sul
d07a0c5554 style: dart format 적용
- 전체 Dart 소스 및 테스트 파일 포매팅 통일
- trailing comma, 줄바꿈, 인덴트 정리
2026-02-13 16:08:23 +09:00
JiWoong Sul
bccb5cb188 docs: 개발 계획 및 감사 보고서 추가
- PLAN.md: 개발 계획 문서
- doc/audit-report-2026-02-13.md: 코드 감사 보고서
2026-02-13 16:08:18 +09:00
JiWoong Sul
6994f4fc9b chore(assets): 앱 아이콘 추가
- 512x512 PNG 아이콘 파일 추가
2026-02-13 16:08:14 +09:00
JiWoong Sul
ea64571eed chore(build): 번들 ID 변경 및 버전 업데이트
- com.example.asciineverdie → com.naturebridgeai.asciineverdie
- 버전 1.0.0+1 → 1.0.1+2
- iOS, macOS, Linux 빌드 설정 일괄 반영
2026-02-13 16:08:11 +09:00
JiWoong Sul
1ff4208f06 docs: 앱스토어 설명 추가 (한/영/일)
- 앱 이름, 간단한 설명, 자세한 설명 포함
- 디지털 판타지 장르 및 게임 특징 소개
2026-01-30 18:46:03 +09:00
JiWoong Sul
067c295163 docs: 개인정보 처리방침 추가 (한/영/일)
- privacy-policy.md: 마크다운 형식
- and-privacy.txt: 텍스트 형식
- 앱 지원 언어에 맞춰 3개 언어로 작성
2026-01-30 18:45:59 +09:00
JiWoong Sul
ea6ebf55f5 chore(l10n): 중국어 로컬라이제이션 제거
- 미완성 중국어 번역 파일 삭제
- supportedLocales에서 zh 제거
- 중국어 기기는 영어로 fallback
2026-01-30 18:43:47 +09:00
JiWoong Sul
41f73bc14c feat(android): 릴리즈 서명 설정 추가
- keystore 파일 추가 (doc/key/askiineverdie.jks)
- key.properties 설정 파일 추가
- build.gradle.kts에 릴리즈 서명 설정 추가
2026-01-21 19:04:03 +09:00
JiWoong Sul
54a2d128aa fix(test): new_character_screen_test 버튼 텍스트 수정
- "UNROLL" → "UNDO" 버튼 텍스트 수정 (l10n.unroll 변경 반영)
2026-01-21 18:43:42 +09:00
JiWoong Sul
73e96bcf50 fix(test): game_play_screen_test 타이머 및 레이아웃 수정
- FakeHallOfFameStorage, FakeStatisticsStorage 사용
- 데스크톱 레이아웃 테스트를 위한 화면 크기 설정 (1200x800)
- 대문자 텍스트 매칭 수정 (CHARACTER SHEET, STATS 등)
- 커스텀 프로그레스 바에 맞게 테스트 수정
- locale 영어 고정으로 테스트 안정성 향상
2026-01-21 18:43:34 +09:00
JiWoong Sul
e37a2ddfa8 fix(test): widget_test 타이머 이슈 수정
- TestSetup 헬퍼 사용으로 SharedPreferences 모킹 통합
- AudioService 타이머 완료를 위한 1초 pump 추가
- tearDown에서 싱글톤 서비스 정리로 타이머 누수 방지
2026-01-21 18:43:26 +09:00
JiWoong Sul
3be9d346dd test(helpers): 테스트 헬퍼 및 Fake 스토리지 추가
- TestSetup 클래스 추가 (SharedPreferences 모킹, 싱글톤 정리)
- FakeHallOfFameStorage: 메모리 기반 명예의 전당 저장소
- FakeStatisticsStorage: 메모리 기반 통계 저장소
- path_provider 의존성 없이 테스트 가능하도록 개선
2026-01-21 18:43:18 +09:00
JiWoong Sul
d9a2fe358c refactor(ui): 화면 및 위젯 정리
- GamePlayScreen build() 메서드 분할 (300→15 LOC)
- 애니메이션/프로그레스 패널 개선
- 설정 화면 정리
2026-01-21 17:34:47 +09:00
JiWoong Sul
faf87eccb0 feat(l10n): 다국어 문자열 추가
- 옵션 메뉴, 통계, 도움말 등 UI 문자열 추가
- en, ko, ja, zh 지원
2026-01-21 17:34:39 +09:00
JiWoong Sul
7f44e95163 refactor(engine): tick() 메서드 분할 (350→80 LOC)
- 8개 헬퍼 메서드로 책임 분리
- _generateNextTask() 35 LOC로 감소
2026-01-21 17:34:31 +09:00
JiWoong Sul
742b0d1773 docs: CHANGELOG 및 아키텍처 문서 추가
- CHANGELOG.md: 리팩토링 내역 기록
- docs/ARCHITECTURE.md: ASCII 다이어그램으로 구조 시각화
2026-01-21 17:34:11 +09:00
JiWoong Sul
97b40ccb1f fix(lint): analyzer 경고 정리
- JsonKey ignore 주석 추가 (equipment_item, monetization_state)
- 미사용 import 제거 (panel_header)
- displayColor → displayColorCode (monster_grade, Flutter 의존성 제거)
2026-01-21 17:34:06 +09:00
JiWoong Sul
75bc39528f refactor(ui): new_character_screen.dart 분할 (1016→544 LOC)
- NameInputSection: 이름 입력 섹션
- StatsSection: 능력치 섹션 (스탯 타일, 롤/언두 버튼)
- RaceSelectionSection: 종족 선택 섹션
- ClassSelectionSection: 직업 선택 섹션
2026-01-21 17:33:59 +09:00
JiWoong Sul
c5eaecfa6a refactor(ui): mobile_carousel_layout.dart 분할 (1220→689 LOC)
- RetroSelectDialog, RetroOptionItem: 선택 다이얼로그
- RetroSoundDialog: 사운드 설정 다이얼로그
- RetroConfirmDialog: 확인 다이얼로그
- RetroMenuSection, RetroMenuItem, RetroSpeedChip: 메뉴 위젯
2026-01-21 17:33:52 +09:00
JiWoong Sul
c577f9deed refactor(controller): GameSessionController 분할 (920→526 LOC)
- GameStatisticsManager: 세션/누적 통계 추적
- SpeedBoostManager: 광고 배속 부스트 기능
- ReturnRewardsManager: 복귀 보상 기능
- ResurrectionManager: 사망/부활 처리
- HallOfFameManager: 명예의 전당 관리
2026-01-21 17:33:37 +09:00
JiWoong Sul
e516076ce8 refactor(model): game_state.dart 분할 (SRP 개선)
- Stats, Traits, Inventory, Equipment 등 11개 파일로 분리
- 단일 책임 원칙 적용으로 유지보수성 향상
2026-01-21 17:33:30 +09:00
JiWoong Sul
7b9f1f87a6 fix(monetization): 버프 종료 버그 수정 (게임 시간 기준 통일)
- 배속 부스트: 실시간 타이머 → 게임 시간(elapsedMs) 기준 종료
- 자동부활 버프: 만료 시 autoReviveEndMs null 초기화 추가
- 매 틱마다 _checkSpeedBoostExpiry(), _checkAutoReviveExpiry() 호출
- 광고 직후 앱 resume 시 reload 방지 (isRecentlyShowedAd)
- 앱 pause/reload와 무관하게 버프 정상 종료
2026-01-20 18:13:40 +09:00
JiWoong Sul
2b4ea44623 fix(ui): 배속 버튼 오버플로우 수정 (52→56px) 2026-01-20 16:28:23 +09:00
JiWoong Sul
d5c46ad04a refactor(ui): 화면 UI 정리
- front_screen: 프론트 화면 레이아웃 개선
- settings_screen: 설정 화면 간소화
- new_character_screen: 캐릭터 생성 화면 정리
2026-01-19 19:41:01 +09:00
JiWoong Sul
71740abe8f refactor(service): 서비스 로직 정리
- ad_service: 광고 서비스 코드 정리
- debug_settings_service: 디버그 설정 간소화
- stat_calculator: 스탯 계산 로직 정리
- character_roll_service: 캐릭터 롤 로직 수정
2026-01-19 19:40:54 +09:00
JiWoong Sul
0cccc17f1f refactor(model): 전투 상태 및 종족 데이터 수정
- combat_state: 전투 상태 모델 필드 추가
- race_traits: 종족 특성 정리
- race_data: 종족 데이터 업데이트
2026-01-19 19:40:48 +09:00
JiWoong Sul
5cccd28b77 refactor(engine): 전투 및 진행 로직 개선
- combat_tick_service: 전투 틱 처리 로직 확장
- progress_service: 진행 상태 처리 개선
- skill_service: 스킬 시스템 업데이트
- potion_service: 포션 처리 로직 수정
2026-01-19 19:40:42 +09:00
JiWoong Sul
109b4eb678 chore(android): 패키지명 변경 및 빌드 설정 업데이트
- 패키지명 com.example → com.naturebridgeai로 변경
- 기존 MainActivity.kt 삭제
- 새 패키지 경로에 MainActivity.kt 추가
2026-01-19 19:40:36 +09:00
JiWoong Sul
d90543dd86 fix(speed): 배속 관련 버그 수정
- 광고 후 배속 적용 안됨: isShowingAd 플래그로 lifecycle reload 방지
- 배속 종료 후 복귀 안됨: setSpeed(_savedSpeedMultiplier) 추가
- 복귀 상자 장비 장착 안됨: _loop?.replaceState() 추가
- 세이브 로드 시 1배속 고정: 명예의 전당 해금 시 최소 2배속 보장
2026-01-19 19:39:32 +09:00
JiWoong Sul
03ff9c1ce8 refactor(ui): 배속 버튼 UI 단순화
- 1x/2x 사이클 버튼 + 광고배속 버튼 → 5x/20x 토글 버튼 하나로 변경
- 부스트 활성화 중: 반투명, 비활성 상태
- 부스트 비활성화: 불투명, 클릭 가능
- _RetroSpeedChip에 isDisabled 파라미터 추가
2026-01-19 19:39:25 +09:00
JiWoong Sul
94c2ed1ca1 refactor(app): 앱 설정 및 공유 위젯 업데이트
- app.dart: MaterialApp 설정 개선
- retro_panel: 레트로 패널 위젯 수정
2026-01-19 15:50:49 +09:00
JiWoong Sul
19faa9ea39 feat(ui): 게임 화면 및 UI 컴포넌트 개선
- front_screen: 프론트 화면 UI 업데이트
- game_play_screen: 게임 플레이 화면 수정
- game_session_controller: 세션 관리 로직 개선
- mobile_carousel_layout: 모바일 캐러셀 레이아웃 개선
- enhanced_animation_panel: 애니메이션 패널 업데이트
- help_dialog: 도움말 다이얼로그 수정
- return_rewards_dialog: 복귀 보상 다이얼로그 개선
- new_character_screen: 새 캐릭터 화면 수정
- settings_screen: 설정 화면 업데이트
2026-01-19 15:50:35 +09:00
JiWoong Sul
ffc19c7ca6 refactor(core): 핵심 서비스 로직 개선
- audio_service: 오디오 처리 로직 수정
- ad_service: 광고 서비스 개선
- character_roll_service: 캐릭터 롤 로직 수정
- iap_service: 인앱 결제 로직 개선
- progress_loop: 진행 루프 업데이트
- return_rewards_service: 복귀 보상 로직 개선
- settings_repository: 설정 저장소 수정
2026-01-19 15:50:18 +09:00
JiWoong Sul
724de9a63c feat(l10n): 다국어 텍스트 업데이트
- 영어, 한국어, 일본어, 중국어 번역 업데이트
- game_text_l10n 데이터 개선
2026-01-19 15:50:02 +09:00
JiWoong Sul
03aa117710 chore(deps): package_info_plus 패키지 추가
- 앱 버전 정보 표시를 위한 패키지 추가
- macos 플랫폼 설정 업데이트
2026-01-19 15:49:48 +09:00
JiWoong Sul
f51bf8c540 feat(core): 보물 상자 시스템 추가
- TreasureChest 모델 추가
- ChestService 서비스 추가
2026-01-19 15:49:26 +09:00
JiWoong Sul
d41dd0fb90 docs: 수익화 시스템 문서 추가
- app-ads.txt 광고 인증 파일
- 수익화 시스템 계획 문서
2026-01-16 20:11:13 +09:00
JiWoong Sul
9f077d74a1 chore: 플랫폼 설정 및 테스트 업데이트
- Android 광고 권한 추가
- macOS 플러그인 등록
- 테스트 mock 업데이트
2026-01-16 20:11:00 +09:00
JiWoong Sul
748160d543 feat(ui): 화면 및 컨트롤러 수익화 연동
- 앱 초기화에 광고/IAP 서비스 추가
- 게임 세션 컨트롤러 수익화 상태 관리
- 캐릭터 생성 화면 굴리기 제한 UI
- 설정 화면 광고 제거 구매 UI
- 애니메이션 패널 개선
2026-01-16 20:10:43 +09:00
JiWoong Sul
c95e4de5a4 feat(core): i18n 및 핵심 로직 개선
- 수익화 관련 텍스트 추가
- item_service 수정
- progress_service 수정
2026-01-16 20:10:36 +09:00
JiWoong Sul
c95fb7f4b4 feat(ui): 스피드 부스트 버튼 위젯 추가
- 5배속 버프 활성화 버튼
- 광고 시청으로 버프 획득
- 남은 시간 표시
2026-01-16 20:10:08 +09:00
JiWoong Sul
b6d5cd2abd feat(death): 사망/부활 시스템 개선
- DeathInfo에 lostItem 필드 추가 (광고 부활 시 복구용)
- 세이브 데이터 v4: MonetizationState 포함
- 사망 오버레이 UI 개선
- 부활 서비스 광고 연동
2026-01-16 20:09:52 +09:00
JiWoong Sul
b272ef8f08 feat(rewards): 복귀 보상 시스템 추가
- 시간 경과에 따른 골드 보상 계산
- 광고 시청 시 2배 보너스
- 복귀 보상 다이얼로그 UI
2026-01-16 20:09:32 +09:00
JiWoong Sul
37c118b0f8 feat(character): 캐릭터 롤 서비스 추가
- 굴리기 횟수 제한 및 충전
- 스탯 히스토리 기반 되돌리기
- 광고 시청으로 굴리기 충전
2026-01-16 20:09:16 +09:00
JiWoong Sul
28d3e53bab feat(debug): 디버그 설정 서비스 추가
- 광고/IAP/무적 모드 토글
- 시간 스케일 조절
- SharedPreferences 기반 영속화
2026-01-16 20:08:59 +09:00
JiWoong Sul
77f3f1d46b feat(iap): 인앱 결제 서비스 추가
- 광고 제거 상품 구매 처리
- 구매 복원 기능
- 결제 상태 스트림 지원
2026-01-16 20:08:43 +09:00
JiWoong Sul
6662a5dcfb feat(ads): AdMob 광고 서비스 추가
- 리워드/인터스티셜 광고 로드 및 표시
- 디버그 모드 광고 토글 지원
- 비모바일 플랫폼 자동 스킵
2026-01-16 20:08:27 +09:00
JiWoong Sul
724f08f56d feat(monetization): 수익화 시스템 기반 모델 추가
- MonetizationState freezed 모델 추가
- google_mobile_ads, in_app_purchase 의존성 추가
- IAP 구매 상태, 버프 종료 시점, 복귀 보상 데이터 관리
2026-01-16 20:08:10 +09:00
JiWoong Sul
306715ca26 feat(balance): 레벨 기반 장비 손실 확률 시스템
- 저레벨 사망 스파이럴 방지
- 장비 손실 확률 = (레벨 - 5) * 10%
  - Lv 1~5: 0% (절대 안전)
  - Lv 6: 10%
  - Lv 10: 50%
  - Lv 15+: 100%
- 디버그 로그 추가
2026-01-16 00:17:08 +09:00
JiWoong Sul
9e5472728f refactor(potion): 물약 자동 사용 조건 변경
- 임계치 기반 → 소모량 기반 조건 전환
- HP/MP 소모량 >= 물약 회복량일 때 사용
- emergencyHpThreshold, emergencyMpThreshold 상수 제거
- 우선순위 HP > MP 유지
2026-01-16 00:15:38 +09:00
JiWoong Sul
93f29f6c33 feat(ui): 사망 화면 잃은 아이템 희귀도 색상 표시
- DeathInfo에 lostItemRarity 필드 추가
- 사망 처리 시 아이템 희귀도 저장
- 사망 오버레이에서 희귀도별 색상 적용
  - Common: 회색, Uncommon: 녹색, Rare: 파랑
  - Epic: 보라, Legendary: 주황
2026-01-16 00:13:24 +09:00
JiWoong Sul
a2b5bb7dc0 refactor(ui): 물약 글로벌 쿨타임 적용 UI 정리
- usedPotionTypes/usedInBattle 파라미터 제거
- 전투당 타입별 제한 → 시간 기반 쿨타임 전환
- PotionInventoryPanel 불투명도 로직 제거
2026-01-16 00:12:43 +09:00
JiWoong Sul
b8a4d73461 fix(death): 사망 시 희생 아이템 선택 디버그 로그 추가
- 장비 슬롯 상태 콘솔 로그 추가
- resurrection_service에 lostItemSlot 설정 누락 수정
- resetBattleUsage 존재하지 않는 메서드 호출 제거
2026-01-15 23:33:31 +09:00
JiWoong Sul
7e1936b34f fix(ui): 사망 화면 장비 슬롯명 표시 및 기타 수정
- 사망 시 잃은 아이템에 슬롯명 표시 추가
- progress_service 마이너 수정
- 관련 테스트 업데이트
2026-01-15 23:23:38 +09:00
JiWoong Sul
9599a33a8f style(ui): HP/MP 바 숫자 오버레이 표시
- 웹: hp_mp_bar.dart 숫자를 바 중앙에 오버레이
- 모바일: enhanced_animation_panel.dart 동일 적용
- 텍스트 그림자 추가로 가독성 향상
2026-01-15 23:23:02 +09:00
JiWoong Sul
c41d15405f feat(balance): 버프 스킬 하이브리드 밸런스 조정
- 22개 버프 스킬 효과 감소 + MP 비용 증가
- Tier 1-5 전체 스킬 효율 하향 조정
- 예: Breakpoint CRI 35%→20%, MP 70→120
2026-01-15 23:22:49 +09:00
JiWoong Sul
b0913a24ff feat(skill): DamageType 및 magAtk/magDef 스킬 시스템 추가
- DamageType enum 추가 (physical/magical)
- 스킬별 데미지 타입 지정 기능 구현
- 마법 스킬 데미지에 magAtk/magDef 적용
- 장비 아이템에서 magAtk/magDef 스탯 추출
- 관련 테스트 업데이트
2026-01-15 23:22:36 +09:00
JiWoong Sul
525e231c06 style(ui): victory_overlay ASCII 아트 정렬 개선
- 트로피 ASCII 좌우 공백 균형 조정
- THE END 텍스트 FittedBox로 자동 스케일링
- 폰트 크기 14→12 조정
2026-01-15 21:34:30 +09:00
JiWoong Sul
58cc1fddb5 feat(ui): 스킬 상세 정보 ExpansionTile 구현
- _SkillRow → _SkillTile (ExpansionTile 기반)
- 타입별 스탯 그리드 표시 (공격/회복/버프/디버프)
- 메타 행: 티어(로마숫자), 타입명, 속성명
- RetroColors 적용, 타입/속성별 컬러 구분
- 한/영/일 3개 언어 지원
2026-01-15 21:34:24 +09:00
JiWoong Sul
60db6b2ec9 feat(l10n): 스킬 상세 정보 라벨 추가
- 공통: tier, mpCost, cooldown, seconds
- 공격: power, hits, dot, lifesteal, defPen, selfDmg
- 회복: healFixed, healPercent, mpHeal
- 버프/디버프: duration, atkMod, defMod, criMod, evaMod
- 타입 이름 4개, 속성 이름 8개
2026-01-15 21:34:18 +09:00
196 changed files with 22901 additions and 10762 deletions

46
CHANGELOG.md Normal file
View File

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

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 프로젝트 개요
Askii Never Die는 Progress Quest 6.4 (Delphi 원본)를 Flutter로 100% 동일하게 복제하는 오프라인 싱글플레이어 RPG입니다. 네트워크 기능은 모두 제외되며, 원본 알고리즘과 데이터를 그대로 유지해야 합니다.
Askii Never Die는 Progress Quest 6.4의 핵심 메커니즘을 기반으로, 독자적 세계관("디지털 판타지")과 확장된 시스템으로 재구성한 오프라인 싱글플레이어 방치형 RPG입니다. 네트워크 기능은 제외됩니다.
## 빌드 및 실행
@@ -27,29 +27,53 @@ flutter test
```
lib/
├── main.dart # 앱 진입점
├── data/pq_config_data.dart # PQ 정적 데이터 (Config.dfm 추출)
├── main.dart # 앱 진입점
├── data/ # 정적 데이터 (Config.dfm 추출 + 확장)
│ ├── pq_config_data.dart # PQ 원본 정적 데이터
│ ├── class_data.dart # 직업 데이터
│ ├── race_data.dart # 종족 데이터
│ ├── skill_data.dart # 스킬 데이터
│ ├── potion_data.dart # 포션 데이터
│ ├── story_data.dart # 스토리 데이터
│ └── game_text_l10n.dart # 게임 텍스트 번역
├── l10n/ # 앱 UI 다국어 리소스 (arb)
└── src/
├── app.dart # MaterialApp 설정
├── app.dart # MaterialApp 설정
├── core/
│ ├── engine/ # 게임 루프 및 진행 로직
│ │ ├── progress_loop.dart # 타이머 기반 메인 루프 (원본 200ms)
│ │ ├── progress_service.dart # 틱 처리, 경험치/레벨업 로직
│ │ ├── game_mutations.dart # 상태 변경 함수
│ │ ── reward_service.dart # 보상 처리
├── model/
│ │ ├── game_state.dart # 핵심 상태: Traits, Stats, Inventory, Equipment, SpellBook, ProgressState, QueueState
│ │ ├── pq_config.dart # Config 데이터 접근
│ │ ├── equipment_slot.dart # 장비 슬롯 정의
│ │ ── save_data.dart # 저장 데이터 구조
├── storage/ # 세이브 파일 처리
└── util/
├── deterministic_random.dart # 결정론적 RNG (재현 가능)
── pq_logic.dart # 원본 로직 포팅 (odds, randSign 등)
└── roman.dart # 로마 숫자 변환
└── features/
├── front/front_screen.dart # 임시 프론트 화면
── game/game_session_controller.dart # 게임 세션 관리
│ ├── engine/ # 게임 루프 및 진행 로직
│ │ ├── progress_loop.dart # 타이머 기반 메인 루프
│ │ ├── progress_service.dart # 틱 처리, 경험치/레벨업 로직
│ │ ├── game_mutations.dart # 상태 변경 함수
│ │ ── reward_service.dart # 보상 처리
│ ├── combat_calculator.dart # 전투 계산
│ │ ├── combat_tick_service.dart # 전투 틱 처리
│ │ ├── arena_service.dart # 아레나 시스템
│ │ ├── skill_service.dart # 스킬 시스템
│ │ ── item_service.dart # 아이템 처리
│ ├── potion_service.dart # 포션 시스템
│ ├── shop_service.dart # 상점 시스템
├── story_service.dart # 스토리 진행
── ... # 기타 서비스
├── model/ # 게임 상태 및 데이터 모델
│ ├── animation/ # ASCII 애니메이션 데이터/렌더링
├── audio/ # 오디오 서비스
── storage/ # 세이브/설정 저장소
│ ├── notification/ # 알림 서비스
│ ├── constants/ # 상수 정의
│ ├── l10n/ # 게임 데이터 번역 유틸
│ └── util/ # 유틸리티 (RNG, 로직 헬퍼 등)
├── features/
│ ├── front/ # 타이틀/세이브 선택 화면
│ ├── new_character/ # 캐릭터 생성 화면
│ ├── game/ # 게임 진행 화면 (메인)
│ │ ├── controllers/ # 전투 로그, 오디오 컨트롤러
│ │ ├── managers/ # 통계, 부활, 속도 부스트 등
│ │ ├── pages/ # 탭별 페이지 (장비, 인벤토리, 퀘스트 등)
│ │ └── widgets/ # UI 위젯
│ ├── arena/ # 아레나 전투 화면
│ ├── hall_of_fame/ # 명예의 전당
│ └── settings/ # 설정 화면
└── shared/ # 공통 테마/위젯
example/pq/ # Delphi 원본 소스 (참조용, 빌드 대상 아님)
test/ # 단위/위젯 테스트
@@ -69,10 +93,9 @@ test/ # 단위/위젯 테스트
## 핵심 규칙
### 원본 충실도
- `example/pq/` 내 Delphi 소스의 알고리즘/데이터를 100% 동일하게 포팅
- 원본 로직 변경 필요 시 반드시 사용자 승인 필요
- 새로운 기능, 값, 처리 로직 추가 금지 (디버깅 로그 예외)
### 원본 참조 정책
- `example/pq/`는 참조용으로 유지
- 원본 알고리즘은 참고하되 독자적 확장/수정 허용
### 데이터 관리
- 정적 데이터(몬스터, 아이템, 주문 등)는 `Config.dfm`에서 추출하여 JSON/Dart const로 관리
@@ -87,11 +110,13 @@ test/ # 단위/위젯 테스트
- SRP(Single Responsibility Principle) 준수
### 화면 구성
- 2개 화면만 사용: 캐릭터 생성 화면, 게임 진행 화면
- 주요 화면: 프론트, 캐릭터 생성, 게임 진행, 아레나, 명예의 전당, 설정
- 화면 내 요소는 위젯 단위로 분리
## 원본 소스 참조 (example/pq/)
> 참고용으로만 사용. 원본 로직을 그대로 따를 의무는 없음.
| 파일 | 핵심 함수/라인 | 역할 |
|------|----------------|------|
| `Main.pas:523-1040` | `MonsterTask` | 전투/전리품/레벨업 |
@@ -105,7 +130,6 @@ test/ # 단위/위젯 테스트
- `pubspec.yaml` 의존성 변경
- 플랫폼 빌드 설정 (Android/iOS/desktop)
- 네트워크 접근 도입
- 원본 데이터/알고리즘 수정
- 대규모 파일 삭제 또는 구조 변경
## 커밋 규칙

250
PLAN.md Normal file
View File

@@ -0,0 +1,250 @@
# 종족/클래스 패시브 미반영 수정 계획
## 1. 현황 분석
### 반영되는 패시브 (전투 스탯 계산에 적용됨)
- HP/MP 보너스, 물리/마법 데미지 보너스, 방어력/회피율/크리티컬 보너스
### 미반영 패시브 (정의만 있고 실제 로직에서 미사용)
| 패시브 | 영향받는 종족/클래스 | 수정 위치 |
|--------|---------------------|-----------|
| `expMultiplier` | Byte Human (+5%), Callback Seraph (+3%) | `progress_service.dart:387` |
| `firstStrikeBonus` | Pointer Assassin (1.5배) | `combat_tick_service.dart` |
| `multiAttack` | Refactor Monk | `combat_tick_service.dart` |
| `postCombatHeal` | Garbage Collector (+5%) | `progress_service.dart:279` |
| `healingBonus` | Debugger Paladin, Exception Handler, Null Checker | `potion_service.dart`, `skill_service.dart` |
| `deathEquipmentPreserve` | Coredump Undead | **특성 변경 필요** |
---
## 2. 수정 내용
### 2.1 경험치 배율 (`expMultiplier`)
**파일**: `lib/src/core/engine/progress_service.dart`
**위치**: 384-387줄
**현재 코드**:
```dart
if (gain && nextState.traits.level < 100 && monsterExpReward > 0) {
final newExpPos = progress.exp.position + monsterExpReward;
```
**수정 후**:
```dart
if (gain && nextState.traits.level < 100 && monsterExpReward > 0) {
// 종족 경험치 배율 적용 (예: Byte Human +5%)
final race = RaceData.findById(nextState.traits.raceId);
final expMultiplier = race?.expMultiplier ?? 1.0;
final adjustedExp = (monsterExpReward * expMultiplier).round();
final newExpPos = progress.exp.position + adjustedExp;
```
---
### 2.2 첫 공격 배율 (`firstStrikeBonus`)
**파일**: `lib/src/core/engine/combat_tick_service.dart`
**설계**:
- 전투 시작 시 첫 공격인지 추적하는 플래그 필요
- 첫 공격 시 `firstStrikeBonus` 배율 적용
**수정 방안**:
1. `CombatState``isFirstAttack` 플래그 추가
2. `CombatTickService`에서 첫 플레이어 공격 시 배율 적용:
```dart
var damage = result.damage;
if (isFirstPlayerAttack && firstStrikeBonus > 1.0) {
damage = (damage * firstStrikeBonus).round();
isFirstPlayerAttack = false;
}
```
---
### 2.3 연속 공격 (`multiAttack`)
**파일**: `lib/src/core/engine/combat_tick_service.dart`
**설계**:
- `hasMultiAttack` 패시브가 있으면 일정 확률로 추가 공격
- 예: 30% 확률로 연속 공격 (2타)
**수정 방안**:
```dart
// 플레이어 공격 후
if (hasMultiAttack && rng.nextDouble() < 0.3) {
// 추가 공격 실행
final extraAttack = calculator.playerAttackMonster(...);
// 결과 합산
}
```
---
### 2.4 전투 후 HP 회복 (`postCombatHeal`)
**파일**: `lib/src/core/engine/progress_service.dart`
**위치**: 276-280줄
**현재 코드**:
```dart
// 전투 승리 시 HP 회복 (50% + CON/2)
final conBonus = nextState.stats.con ~/ 2;
final healAmount = (maxHp * 0.5).round() + conBonus;
```
**수정 후**:
```dart
// 전투 승리 시 HP 회복 (50% + CON/2 + 클래스 패시브)
final conBonus = nextState.stats.con ~/ 2;
var healAmount = (maxHp * 0.5).round() + conBonus;
// 클래스 패시브: 전투 후 HP 회복 (Garbage Collector +5%)
final klass = ClassData.findById(nextState.traits.classId);
if (klass != null) {
final postCombatHealRate = klass.getPassiveValue(ClassPassiveType.postCombatHeal);
if (postCombatHealRate > 0) {
healAmount += (maxHp * postCombatHealRate).round();
}
}
```
---
### 2.5 회복력 보너스 (`healingBonus`)
물약/스킬 사용 시 추가 회복 적용
#### 2.5.1 물약 회복
**파일**: `lib/src/core/engine/potion_service.dart`
**수정 위치**: `usePotion()` 메서드 (73-81줄)
**현재 코드**:
```dart
if (potion.isHpPotion) {
healedAmount = potion.calculateHeal(maxHp);
newHp = (currentHp + healedAmount).clamp(0, maxHp);
```
**수정 후**:
```dart
if (potion.isHpPotion) {
var baseHeal = potion.calculateHeal(maxHp);
// 회복력 보너스 적용 (클래스 패시브)
baseHeal = (baseHeal * healingMultiplier).round();
newHp = (currentHp + baseHeal).clamp(0, maxHp);
healedAmount = newHp - currentHp;
```
**참고**: `PotionService``healingMultiplier` 파라미터 추가 필요
#### 2.5.2 스킬 회복
**파일**: `lib/src/core/engine/skill_service.dart`
**수정 위치**: `useHealSkill()` 메서드 (125-132줄)
**현재 코드**:
```dart
int healAmount = skill.healAmount;
if (skill.healPercent > 0) {
healAmount += (player.hpMax * skill.healPercent).round();
}
```
**수정 후**:
```dart
int healAmount = skill.healAmount;
if (skill.healPercent > 0) {
healAmount += (player.hpMax * skill.healPercent).round();
}
// 회복력 보너스 적용 (클래스 패시브)
healAmount = (healAmount * healingMultiplier).round();
```
**참고**: `SkillService``healingMultiplier` 파라미터 추가 필요
---
### 2.6 Coredump Undead 특성 변경
**현재 특성**: `deathEquipmentPreserve` (사망 시 장비 1개 유지) - BM 침해
**대체 특성 제안** (언데드 콘셉트에 어울리는 것):
| 옵션 | 설명 | 장점 |
|------|------|------|
| **방어력 +10%** | 언데드는 고통을 느끼지 않아 피해 감소 | 구현 간단, CON+2와 시너지 |
| **HP +8%** | 불사의 육체 | 구현 간단, 생존형 콘셉트 유지 |
| **HP +5% + 방어력 +5%** | 복합 생존 특화 | 다른 종족과 차별화 |
**추천**: `defenseBonus: 0.10` (방어력 +10%)
- 이유: 언데드의 "고통을 느끼지 않는" 콘셉트와 어울림
- 기존 CON+2, STR+1 스탯과 탱커형 시너지
**파일**: `lib/data/race_data.dart`
**수정**:
```dart
static const coredumpUndead = RaceTraits(
raceId: 'coredump_undead',
name: 'Coredump Undead',
statModifiers: {
StatType.con: 2,
StatType.str: 1,
StatType.cha: -2,
StatType.dex: -1,
},
passives: [
PassiveAbility(
type: PassiveType.defenseBonus, // 변경
value: 0.10, // 변경
description: '방어력 +10%', // 변경
),
],
);
```
---
## 3. 수정 순서
1. **Coredump Undead 특성 변경** - 단순 데이터 수정
2. **경험치 배율** - 간단한 로직 추가
3. **전투 후 HP 회복** - 간단한 로직 추가
4. **회복력 보너스** - 서비스 파라미터 수정 필요
5. **첫 공격 배율** - 전투 상태 추적 필요
6. **연속 공격** - 전투 로직 수정 필요
---
## 4. 검증 방법
1. `flutter analyze` 통과
2. 각 패시브가 적용된 종족/클래스로 캐릭터 생성
3. 실제 게임 플레이로 효과 확인:
- Byte Human: 경험치 +5% (레벨업 속도)
- Pointer Assassin: 첫 공격 1.5배 (전투 시작 데미지)
- Refactor Monk: 연속 공격 (추가 타격)
- Garbage Collector: 전투 후 +5% HP 회복
- Debugger Paladin: 물약/스킬 회복량 +10%
- Coredump Undead: 방어력 +10%
---
## 5. 삭제할 코드
**파일**: `lib/src/core/model/race_traits.dart`
`PassiveType.deathEquipmentPreserve` enum 삭제 (사용되지 않음)
**파일**: `lib/src/core/engine/stat_calculator.dart`
`calculateDeathEquipmentPreserve()` 메서드 삭제 (사용되지 않음)

View File

@@ -1,3 +1,6 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
id("kotlin-android")
@@ -5,8 +8,15 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
// key.properties 파일 로드
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.example.asciineverdie"
namespace = "com.naturebridgeai.asciineverdie"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -20,21 +30,31 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.asciineverdie"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
applicationId = "com.naturebridgeai.asciineverdie"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
}
}
}

34
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,34 @@
# Flutter 기본 규칙
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }
-keep class io.flutter.util.** { *; }
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
# Google Mobile Ads (AdMob)
-keep class com.google.android.gms.ads.** { *; }
-keep class com.google.ads.** { *; }
# In-App Purchase (Google Play Billing)
-keep class com.android.vending.billing.** { *; }
# Kotlin 직렬화(serialization) 관련
-keepattributes *Annotation*
-keepattributes InnerClasses
# 제네릭(generics) 시그니처 유지
-keepattributes Signature
# Play Core (deferred components) 경고 억제
-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication
-dontwarn com.google.android.play.core.splitinstall.SplitInstallException
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManager
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManagerFactory
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest$Builder
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest
-dontwarn com.google.android.play.core.splitinstall.SplitInstallSessionState
-dontwarn com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener
-dontwarn com.google.android.play.core.tasks.OnFailureListener
-dontwarn com.google.android.play.core.tasks.OnSuccessListener
-dontwarn com.google.android.play.core.tasks.Task

View File

@@ -1,6 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- AdMob 광고 로드에 필요 -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- IAP 결제(billing) 권한 -->
<uses-permission android:name="com.android.vending.BILLING" />
<application
android:label="asciineverdie"
android:label="ASCII Never Die"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<!-- Copyright Protection -->
@@ -29,6 +34,10 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- AdMob App ID -->
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-6691216385521068~8216990571"/>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View File

@@ -1,4 +1,4 @@
package com.example.asciineverdie
package com.naturebridgeai.asciineverdie
import io.flutter.embedding.android.FlutterActivity

4
android/key.properties Normal file
View File

@@ -0,0 +1,4 @@
storePassword=askiineverdie
keyPassword=askiineverdie
keyAlias=askiineverdie
storeFile=../../doc/key/askiineverdie.jks

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

206
doc/and-privacy.txt Normal file
View File

@@ -0,0 +1,206 @@
ASCII Never Die 개인정보 처리방침 / Privacy Policy
================================================================================
한국어 (Korean)
시행일자: 2026년 1월 30일
본 개인정보 처리방침은 ASCII Never Die 앱(이하 "앱")의 개인정보 수집, 이용, 보관 및 보호에 관한 사항을 안내합니다.
1. 수집하는 개인정보
본 앱은 회원가입, 로그인 기능이 없습니다. 이름, 이메일, 전화번호 등의 개인 식별정보를 직접 수집하지 않습니다.
사용자가 입력하는 캐릭터 이름, 게임 진행 데이터(레벨, 장비, 퀘스트 등)는 기기 내에만 저장됩니다.
2. 데이터 저장 및 처리 방식
- 모든 게임 데이터는 로컬 저장소(기기 내 저장소)에만 보관됩니다.
- 클라우드나 외부 서버로 자동 전송되지 않습니다.
- 앱 삭제 시 저장된 모든 데이터가 함께 제거됩니다.
3. 광고 및 제3자 서비스
본 앱은 Google AdMob 광고 네트워크를 사용합니다. 광고 서비스 제공을 위해 다음 정보가 수집될 수 있습니다:
- 광고 식별자(Advertising ID)
- 기기 정보(모델, OS 버전 등)
- 대략적인 위치 정보
- 앱 사용 정보
이러한 정보는 Google의 개인정보 처리방침에 따라 처리됩니다.
- Google 개인정보 처리방침: https://policies.google.com/privacy
4. 인앱 결제
본 앱은 광고 제거 등의 기능을 위해 인앱 결제를 제공합니다. 결제 처리는 각 플랫폼(Google Play, Apple App Store)에서 직접 처리하며, 개발사는 결제 정보(카드 번호, 계좌 정보 등)를 수집하거나 저장하지 않습니다.
- Google Play 개인정보 처리방침: https://policies.google.com/privacy
- Apple 개인정보 처리방침: https://www.apple.com/legal/privacy/
5. 권한 사용
권한 용도
--------------- ---------------------------------
네트워크 접근 광고 표시 및 인앱 결제 처리
저장소 접근 게임 데이터 저장
요청된 권한은 해당 용도 외에는 사용되지 않습니다.
6. 아동의 개인정보
본 앱은 일반 사용자를 대상으로 설계되었으며, 만 14세 미만의 아동을 대상으로 개인정보를 수집하지 않습니다.
7. 개인정보의 보호
- 모든 게임 데이터는 기기 내부에만 저장
- 외부 서버로의 개인정보 전송 없음
- 최소한의 필수 권한만 요청
8. 처리방침의 변경
본 개인정보 처리방침이 변경되는 경우, 앱 내 공지 또는 앱 스토어 설명을 통해 안내합니다.
9. 문의처
이메일: naturebridgeai@gmail.com
담당자: NatureBridgeAI 앱개발팀
================================================================================
English
Effective Date: January 30, 2026
This Privacy Policy describes how ASCII Never Die (the "App") collects, uses, stores, and protects your information.
1. Information We Collect
This App does not require account registration or login. We do not directly collect personal identifying information such as your name, email address, or phone number.
Character names and game progress data (level, equipment, quests, etc.) that you enter are stored only on your device.
2. Data Storage and Processing
- All game data is stored locally on your device only.
- No data is automatically transmitted to cloud services or external servers.
- All stored data is deleted when you uninstall the App.
3. Advertising and Third-Party Services
This App uses the Google AdMob advertising network. The following information may be collected for advertising purposes:
- Advertising ID
- Device information (model, OS version, etc.)
- Approximate location information
- App usage information
This information is processed in accordance with Google's Privacy Policy.
- Google Privacy Policy: https://policies.google.com/privacy
4. In-App Purchases
This App offers in-app purchases for features such as ad removal. Payment processing is handled directly by each platform (Google Play, Apple App Store). We do not collect or store any payment information (credit card numbers, account details, etc.).
- Google Play Privacy Policy: https://policies.google.com/privacy
- Apple Privacy Policy: https://www.apple.com/legal/privacy/
5. Permissions
Permission Purpose
--------------- ----------------------------------------------
Network Access Display advertisements and process in-app purchases
Storage Access Save game data
Requested permissions are not used for any purposes other than those stated above.
6. Children's Privacy
This App is designed for general users and does not knowingly collect personal information from children under 14 years of age.
7. Data Protection
- All game data is stored only on your device
- No personal information is transmitted to external servers
- Only essential permissions are requested
8. Changes to This Privacy Policy
If this Privacy Policy is modified, we will notify you through in-app announcements or app store descriptions.
9. Contact Us
Email: naturebridgeai@gmail.com
Contact: NatureBridgeAI App Development Team
================================================================================
日本語 (Japanese)
施行日2026年1月30日
本プライバシーポリシーは、ASCII Never Dieアプリ以下「本アプリ」における個人情報の収集、利用、保管、保護について説明します。
1. 収集する個人情報
本アプリは会員登録・ログイン機能がありません。氏名、メールアドレス、電話番号などの個人識別情報を直接収集することはありません。
ユーザーが入力するキャラクター名、ゲーム進行データ(レベル、装備、クエストなど)は端末内にのみ保存されます。
2. データの保存と処理方法
- すべてのゲームデータはローカルストレージ(端末内)にのみ保管されます。
- クラウドや外部サーバーへ自動送信されることはありません。
- アプリを削除すると、保存されたすべてのデータも削除されます。
3. 広告および第三者サービス
本アプリはGoogle AdMob広告ネットワークを使用しています。広告サービス提供のため、以下の情報が収集される場合があります
- 広告識別子Advertising ID
- 端末情報機種、OSバージョンなど
- おおよその位置情報
- アプリ使用情報
これらの情報はGoogleのプライバシーポリシーに従って処理されます。
- Googleプライバシーポリシーhttps://policies.google.com/privacy
4. アプリ内課金
本アプリは広告削除などの機能のためにアプリ内課金を提供しています。決済処理は各プラットフォームGoogle Play、Apple App Storeが直接行い、開発者は決済情報カード番号、口座情報などを収集・保存しません。
- Google Playプライバシーポリシーhttps://policies.google.com/privacy
- Appleプライバシーポリシーhttps://www.apple.com/legal/privacy/
5. 権限の使用
権限 用途
------------------- ---------------------------------
ネットワークアクセス 広告表示およびアプリ内課金処理
ストレージアクセス ゲームデータの保存
要求された権限は、上記の用途以外には使用されません。
6. 児童の個人情報
本アプリは一般ユーザーを対象として設計されており、14歳未満の児童から個人情報を収集することはありません。
7. 個人情報の保護
- すべてのゲームデータは端末内にのみ保存
- 外部サーバーへの個人情報送信なし
- 最小限の必要な権限のみを要求
8. プライバシーポリシーの変更
本プライバシーポリシーが変更される場合、アプリ内通知またはアプリストアの説明を通じてお知らせします。
9. お問い合わせ
メール: naturebridgeai@gmail.com
担当者: NatureBridgeAI アプリ開発チーム
================================================================================
Last updated: January 30, 2026

193
doc/app-description.txt Normal file
View File

@@ -0,0 +1,193 @@
================================================================================
한국어 (Korean)
================================================================================
[앱 이름]
ASCII Never Die
[간단한 설명] (80자 이하)
코드의 신이 창조한 디지털 왕국. 글리치 신을 무찌르고 세계를 구하라!
[자세한 설명]
태초에 오직 공허만이 있었다.
그리고 첫 번째 커밋이 일어났고, 코드베이스에 빛이 가득 찼다.
코드의 신이 말씀하셨다. "함수가 있으라."
그렇게 디지털 왕국이 탄생했다.
그러나 어둠 속에서 글리치가 나타났다.
이제, 새로운 영웅이 코드를 지키기 위해 깨어난다.
당신의 여정이 시작된다...
▶ 디지털 판타지의 세계
ASCII Never Die는 프로그래밍과 판타지가 융합된 독특한 세계관의 방치형 RPG입니다. 텍스트와 기호로 이루어진 세계에서, 당신만의 영웅이 글리치 신에 맞서 싸웁니다.
▶ 당신은 누구인가요?
묵묵히 코드를 지키는 Byte Human?
우아하게 null을 다루는 Null Elf?
아니면 메모리 심연에서 돌아온 Coredump Undead?
21가지 종족, 18가지 직업.
378가지 조합 중 당신의 이야기는 어떻게 시작될까요?
Bug Hunter가 되어 버그를 사냥할 수도,
Compiler Mage가 되어 마법을 컴파일할 수도,
Garbage Collector가 되어 적의 메모리를 정리할 수도 있습니다.
▶ 레벨 100까지의 여정
처음엔 작은 버그들과 싸우게 됩니다.
"이 정도는 쉽네" 하고 생각할 겁니다.
그러다 어느 순간, 화면에 거대한 이름이 뜹니다.
심장이 두근거리기 시작합니다.
5개의 막. 5번의 전환점. 그리고 마지막에 기다리는 것...
직접 확인해보세요.
▶ 방치형, 그러나 빠져드는
캐릭터를 만들면 모험이 시작됩니다. 전투, 레벨업, 장비 획득, 주문 습득—
모든 것이 자동으로 진행됩니다. 하지만 프로그레스 바가 차오르는 것을 멈출 수 없을 겁니다.
"조금만 더... 다음 레벨업까지만..."
▶ 완전 오프라인
인터넷 없이 언제 어디서나. 지하철에서, 비행기에서, 침대에서.
당신의 영웅은 항상 당신과 함께합니다.
첫 번째 커밋을 시작하세요. 디지털 왕국이 당신을 기다립니다.
================================================================================
English
================================================================================
[App Name]
ASCII Never Die
[Short Description] (Under 80 characters)
The Code God's kingdom awaits. Defeat the Glitch. Save the digital realm.
[Full Description]
In the beginning, there was only the Void.
Then came the First Commit, and Light filled the Codebase.
The Code God spoke: "Let there be Functions."
And so the Digital Realm was born.
But from the shadows emerged the Glitch.
Now, a new hero awakens to defend the Code.
Your journey begins...
▶ The World of Digital Fantasy
ASCII Never Die is an idle RPG with a unique world where programming meets fantasy. In a realm made of text and symbols, your hero fights against the Glitch God.
▶ Who Will You Be?
A steadfast Byte Human, guardian of the code?
An elegant Null Elf, master of the void?
Or perhaps a Coredump Undead, risen from the depths of memory?
21 races. 18 classes.
How will your story begin among 378 possibilities?
Become a Bug Hunter and squash bugs.
Become a Compiler Mage and compile your spells.
Become a Garbage Collector and clean up your enemies.
▶ The Journey to Level 100
At first, you'll fight small bugs.
"This is easy," you'll think.
Then suddenly, a massive name appears on screen.
Your heart starts pounding.
5 acts. 5 turning points. And what awaits at the end...
Find out for yourself.
▶ Idle, Yet Addictive
Create a character and the adventure begins. Combat, leveling, loot, spells—
everything progresses automatically. But you won't be able to stop watching those progress bars fill.
"Just a little more... just until the next level..."
▶ Fully Offline
No internet needed. Anytime, anywhere. On the subway, on a plane, in bed.
Your hero is always with you.
Make your First Commit. The Digital Realm awaits.
================================================================================
日本語 (Japanese)
================================================================================
[アプリ名]
ASCII Never Die
[簡単な説明] (80文字以下)
コードの神が創造したデジタル王国。グリッチ神を倒し、世界を救え!
[詳細な説明]
太初、ただ虚無のみがあった。
そして最初のコミットが起こり、コードベースに光が満ちた。
コードの神は言われた。「関数あれ。」
こうしてデジタル王国が生まれた。
しかし闇の中からグリッチが現れた。
今、新たな英雄がコードを守るために目覚める。
あなたの旅が始まる...
▶ デジタルファンタジーの世界
ASCII Never Dieは、プログラミングとファンタジーが融合したユニークな世界観の放置型RPGです。テキストと記号で作られた世界で、あなただけの英雄がグリッチ神に立ち向かいます。
▶ あなたは誰になる?
黙々とコードを守るByte Human
優雅にnullを操るNull Elf
それともメモリの深淵から蘇ったCoredump Undead
21種族、18職業。
378通りの中で、あなたの物語はどう始まる
Bug Hunterになってバグを狩るもよし。
Compiler Mageになって魔法をコンパイルするもよし。
Garbage Collectorになって敵のメモリを掃除するもよし。
▶ レベル100への旅
最初は小さなバグと戦うことになります。
「これなら楽勝」と思うでしょう。
でもある瞬間、画面に巨大な名前が現れます。
心臓がドキドキし始めます。
5幕。5つの転換点。そして最後に待つもの...
自分の目で確かめてください。
▶ 放置型、でもハマる
キャラクターを作れば冒険が始まります。戦闘、レベルアップ、装備獲得、呪文習得—
すべてが自動で進行します。でも、プログレスバーが埋まっていくのを止められないでしょう。
「もう少しだけ...次のレベルアップまで...」
▶ 完全オフライン
インターネット不要。いつでも、どこでも。電車で、飛行機で、ベッドで。
あなたの英雄は常にあなたと共に。
最初のコミットを始めましょう。デジタル王国があなたを待っています。
================================================================================

View File

@@ -0,0 +1,521 @@
# ASCII Never Die - 프로젝트 종합 감사 리포트
> 감사일: 2026-02-13
> 검사 수행: 7개 전문 에이전트 병렬 검사
> 대상: 코드 품질, 빌드/테스트, 출시 준비, 사업/수익화, 보안, 로컬라이제이션/접근성, 원본 충실도
---
## 0. 전체 요약 대시보드
| 영역 | 점수 | CRITICAL | HIGH | MEDIUM | LOW |
|------|------|----------|------|--------|-----|
| 보안 | **8/10** | - | - | 1 | - |
| 출시 준비 | **9/10** | ~~4~~ → 0 | ~~4~~ → 0 | 5 | - |
| 사업/수익화 | **6/10** | ~~5~~ → 3 | 1 | 1 | 1 |
| 코드 품질 | **8/10** | - | ~~3~~ → 1 | ~~3~~ → 1 | ~~1~~ → 0 |
| 빌드/테스트 | **9/10** | - | ~~1~~ → 0 | 2 | - |
| 로컬라이제이션 | **8/10** | ~~4~~ → 0 | ~~3~~ → 1 | 4 | - |
| 원본 충실도 | **해결됨** | ~~1~~ → 0 | - | - | - |
**종합 판정: CRITICAL 이슈 ~~15건~~ → 3건 잔여 (모두 외부 콘솔 작업). 코드 작업 가능 항목 대부분 해결 완료.**
> **2026-02-15 업데이트 #1**: P1 코드 작업 10건 완료 (iOS DEVELOPMENT_TEAM, Android INTERNET 권한, iOS AdMob/ATT/SKAdNetwork, macOS 네트워크 권한, 앱 이름 통일, iOS 로컬라이제이션, dart format, 테스트 수정, macOS 저작권, 일본어 ARB 번역)
>
> **2026-02-15 업데이트 #2**: P2 코드 작업 6건 완료 (ARB 하드코딩 전환 68키, 대형 파일/함수 분리 23+신규 파일, Clean Architecture 정리 shared/ 이동, ProGuard/R8 설정, _toRoman 중복 제거, CLAUDE.md 현행화)
---
## 1. 보안
### 1.1 해당 없음 (소유자 확인 완료)
| # | 이슈 | 소유자 판단 |
|---|------|------------|
| ~~S1~~ | ~~JKS 키스토어가 Git에 추적 중~~ | **해당 없음** - 개인 비공개 저장소이므로 문제 없음 |
| ~~S2~~ | ~~key.properties 평문 비밀번호 Git 노출~~ | **해당 없음** - 개인 비공개 저장소이므로 문제 없음 |
> **참고**: 저장소가 공개(public)로 전환되거나 팀 협업으로 확장될 경우 재검토 필요
### 1.2 WARNING
- `.vscode/`, `PLAN.md`가 추적되지 않은 상태로 존재
### 1.4 양호 항목
| 항목 | 상태 |
|------|------|
| 개인정보 처리방침 | 3개국어 준비 완료 (`doc/privacy-policy.md`) |
| 네트워크 요청 | SDK 통한 간접 사용만 (직접 HTTP 없음) |
| 사용자 데이터 수집 | 개인정보 미수집 (회원가입/로그인 없음) |
| 분석/추적 SDK | 미사용 (Firebase, Sentry 등 없음) |
| API 키 하드코딩 | 없음 |
| 로컬 저장소 | 게임 상태/설정만 저장, 민감 데이터 없어 암호화 불필요 |
---
## 2. 출시 준비 상태 - ~~7개~~ 0개 CRITICAL (모두 해결)
### 2.1 CRITICAL (출시 차단)
| # | 이슈 | 상세 |
|---|------|------|
| ~~R1~~ | ~~iOS Bundle ID = `com.example.asciineverdie`~~ | **수정 완료** - `com.naturebridgeai.asciineverdie`로 변경됨 |
| ~~R2~~ | ~~macOS Bundle ID = `com.example.asciineverdie`~~ | **수정 완료** - `com.naturebridgeai.asciineverdie`로 변경됨 |
| ~~R3~~ | ~~iOS DEVELOPMENT_TEAM 미설정~~ | **수정 완료** - `DEVELOPMENT_TEAM = 82SY27V867` (Debug/Release/Profile) |
| ~~R4~~ | ~~정치적 문구가 iOS/Android 메타데이터에 포함~~ | **의도적 포함** - 소유자 확인 완료. 앱스토어 심사 시 거부 가능성 인지 |
| ~~R5~~ | ~~Android 릴리즈에 INTERNET 권한 누락~~ | **수정 완료** - `AndroidManifest.xml`(main)에 INTERNET 권한 추가 |
| ~~R6~~ | ~~iOS `GADApplicationIdentifier` 누락~~ | **수정 완료** - `Info.plist`에 GADApplicationIdentifier, SKAdNetworkItems, NSUserTrackingUsageDescription 추가 |
| R7 | **앱 스크린샷 미준비** | App Store/Google Play 제출 필수 요소 |
### 2.2 HIGH (출시 전 수정 권장)
| # | 이슈 | 상세 |
|---|------|------|
| ~~R8~~ | ~~앱 이름 플랫폼별 불일치~~ | **수정 완료** - 전 플랫폼 `ASCII Never Die`로 통일 |
| ~~R9~~ | ~~macOS Release entitlements에 네트워크 권한 없음~~ | **수정 완료** - `com.apple.security.network.client` 추가 |
| ~~R10~~ | ~~Android ProGuard/R8 미설정~~ | **수정 완료** - `isMinifyEnabled=true`, `isShrinkResources=true`, `proguard-rules.pro` 추가 |
| ~~R11~~ | ~~macOS PRODUCT_COPYRIGHT = `Copyright 2025 com.example`~~ | **수정 완료** - `Copyright © 2025 naturebridgeai`로 변경 |
### 2.3 MEDIUM
- Android minSdk/targetSdk가 Flutter 기본값 의존 (명시적 설정 권장)
- iOS Podfile에서 platform 버전 주석 처리됨
- 스플래시 화면이 기본 흰색 배경 (브랜딩 스플래시 권장)
- Flavor/환경 분리 없음 (AdMob 테스트/프로덕션 분리 불가)
- flutter_launcher_icons에 macOS 설정 없음
### 2.4 플랫폼별 상세
#### iOS
| 항목 | 설정값 | 상태 |
|------|--------|------|
| CFBundleDisplayName | `ASCII Never Die` | **수정 완료** |
| PRODUCT_BUNDLE_IDENTIFIER | `com.naturebridgeai.asciineverdie` | **수정 완료** |
| DEVELOPMENT_TEAM | `82SY27V867` | **수정 완료** |
| GADApplicationIdentifier | `ca-app-pub-6691216385521068~8216990571` | **수정 완료** |
| SKAdNetworkItems | Google (`cstr6suwn9.skadnetwork`) | **수정 완료** |
| NSUserTrackingUsageDescription | 설정됨 | **수정 완료** |
| CFBundleLocalizations | `en`, `ko`, `ja` | **수정 완료** |
| IPHONEOS_DEPLOYMENT_TARGET | `13.0` | OK |
| 앱 아이콘 | 전 사이즈 존재 (20~1024px) | OK |
| LaunchScreen | 기본 Flutter 템플릿 | 개선 권장 |
#### Android
| 항목 | 설정값 | 상태 |
|------|--------|------|
| applicationId | `com.naturebridgeai.asciineverdie` | OK |
| android:label | `ASCII Never Die` | **수정 완료** |
| 릴리즈 서명 | key.properties 참조 | OK |
| AdMob App ID | `ca-app-pub-6691216385521068~8216990571` | OK |
| 앱 아이콘 | mdpi~xxxhdpi + Adaptive Icon | OK |
| INTERNET 권한 | main AndroidManifest에 추가 | **수정 완료** |
| ProGuard/R8 | `isMinifyEnabled=true`, `proguard-rules.pro` | **수정 완료** |
#### macOS
| 항목 | 설정값 | 상태 |
|------|--------|------|
| PRODUCT_BUNDLE_IDENTIFIER | `com.naturebridgeai.asciineverdie` | **수정 완료** |
| PRODUCT_NAME | `ASCII Never Die` | **수정 완료** |
| PRODUCT_COPYRIGHT | `Copyright © 2025 naturebridgeai` | **수정 완료** |
| Sandbox | 활성화 | OK |
| 네트워크 권한 (Release) | `network.client` 추가 | **수정 완료** |
| MACOSX_DEPLOYMENT_TARGET | `10.15` | OK |
| 앱 아이콘 | 16~1024px 존재 | OK |
---
## 3. 사업/수익화
### 3.1 현재 구현 상태
> **참고**: 사용자는 "IAP가 아직 설정이 안되어있다"고 인지하고 있으나, 실제로는 IAP와 AdMob 코드가 **이미 구현되어 있고 프로덕션 ID만 미설정** 상태임.
| 수익원 | 코드 구현 | 프로덕션 준비 | 준비도 |
|--------|----------|-------------|--------|
| 리워드 광고 (부활/되돌리기) | 구현됨 (`ad_service.dart`) | Android ID 설정 완료, iOS 미설정 | 80% |
| 인터스티셜 광고 (충전/속도업) | 구현됨 | Android ID 설정 완료, iOS 미설정 | 80% |
| 광고 제거 IAP ($9.99) | 구현됨 (`iap_service.dart`) | 스토어 상품 미등록 | 50% |
### 3.2 CRITICAL
| # | 이슈 |
|---|------|
| B1 | 프로덕션 광고 단위 ID - **Android 완료**, iOS 플레이스홀더 잔여 (`ad_service.dart:77,81`) |
| ~~B2~~ | ~~iOS AdMob Info.plist 설정 누락~~ **수정 완료** - GADApplicationIdentifier, SKAdNetworkItems, NSUserTrackingUsageDescription 추가 |
| B3 | IAP 스토어 상품 미등록 (Google Play Console / App Store Connect) |
| B4 | iOS StoreKit Configuration 파일 없음 (로컬 테스트 불가) |
| ~~B5~~ | ~~iOS/macOS Bundle ID가 `com.example`~~ **수정 완료** - `com.naturebridgeai.asciineverdie`로 변경됨 |
### 3.3 앱스토어 메타데이터
| 항목 | 상태 | 위치 |
|------|------|------|
| 앱 설명 (한/영/일) | 완비 | `doc/app-description.txt` |
| 간단한 설명 (80자) | 완비 | 각 언어별 준비 |
| 개인정보 처리방침 | 완비 (3개국어) | `doc/privacy-policy.md` |
| 앱 스크린샷 | **미준비** | - |
| 프로모션 텍스트 | 미확인 | - |
| 랜딩 페이지/웹사이트 | 미준비 | - |
### 3.4 수익 모델 리스크 분석
| 리스크 | 설명 | 권장 |
|--------|------|------|
| 원작 무료 | Progress Quest는 완전 무료 오픈소스 - 클론 유료화 반감 가능 | 무료+광고 모델 유지, IAP 가격 인하 권장 |
| 광고 제거 $9.99 | 방치형 RPG 장르 대비 **2~3배 높은 가격** (통상 $2.99~$4.99) | $2.99~$4.99로 인하 권장 |
| 오프라인 전용 | 광고 노출에 네트워크 필요 - 오프라인 시 광고 수익 없음 | 인지 필요 |
| 일회성 수익 | 광고 제거 IAP 한 번이면 이후 수익 제로 | 코스메틱 IAP 추가 고려 |
| 저작권 | 원본 알고리즘/구조 사용 - PQ 저작자와의 관계 정리 필요 | 법률 검토 권장 |
### 3.5 Bundle ID 일관성
| 플랫폼 | Bundle ID | 상태 |
|--------|-----------|------|
| Android | `com.naturebridgeai.asciineverdie` | OK |
| iOS | `com.naturebridgeai.asciineverdie` | **수정 완료** |
| macOS | `com.naturebridgeai.asciineverdie` | **수정 완료** |
---
## 4. 빌드/테스트/정적분석
### 4.1 실행 결과
| 단계 | 결과 | 상세 |
|------|------|------|
| `flutter pub get` | **통과** | 의존성 정상 설치, 31개 패키지 업데이트 가능 |
| `dart format --set-exit-if-changed .` | **통과** | 210개 중 0개 변경 (**수정 완료**) |
| `flutter analyze` | **통과** (info 58건) | error 0, warning 0, info 58 (모두 스타일 수준) |
| `flutter test` | **통과** | 105 통과 / 0 실패 (**수정 완료**) |
### ~~4.2 포맷 미준수 주요 파일~~ - **수정 완료** (42개 파일 자동 포맷 적용됨)
### 4.3 정적분석 이슈 (56건 info)
| 유형 | 건수 | 위치 |
|------|------|------|
| `unnecessary_brace_in_string_interps` | 4 | `lib/data/game_text_l10n.dart` |
| `curly_braces_in_flow_control_structures` | 10 | `lib/data/game_text_l10n.dart` |
| `dangling_library_doc_comments` | 1 | `lib/src/core/util/pq_logic.dart:1` |
| `avoid_print` | ~30 | `test/core/engine/gcd_simulation_test.dart` |
| `prefer_interpolation_to_compose_strings` | 4 | 같은 테스트 파일 |
### ~~4.4 실패 테스트~~ - **수정 완료**
- ~~**파일**: `test/core/engine/skill_service_test.dart:563`~~
- **원인**: `SkillData.debugMode``atkModifier`가 0.25→0.15, `mpCost`가 100→140으로 변경되었으나 테스트가 이전 값을 기대
- **수정**: 테스트 기대값을 현재 데이터에 맞게 업데이트 (0.15, mpCurrent 10)
---
## 5. 코드 품질
### ~~5.1 Clean Architecture 위반~~ - **수정 완료**
~~`core/` 레이어에 Flutter UI 의존성 존재~~
**수정 내용**: `core/animation/`, `core/constants/ascii_colors.dart`, `core/l10n/game_data_l10n.dart` 등 Flutter UI 의존 파일 19개를 `shared/` 디렉토리로 이동. `core/` 레이어는 순수 Dart만 유지.
| 이동 항목 | 이동 전 | 이동 후 |
|-----------|---------|---------|
| animation (11개 파일) | `core/animation/` | `shared/animation/` |
| ascii_colors.dart | `core/constants/` | `shared/theme/` |
| game_data_l10n.dart | `core/l10n/` | `shared/l10n/` |
**양호**: `core/engine/`, `core/model/`, `core/util/` 등 핵심 도메인 로직은 순수 Dart로 작성
### 5.2 SRP 위반 - 대형 파일 - **부분 수정 완료**
**수정 완료**: 12개 대형 파일에서 23+개 신규 파일 추출. 대부분 400 LOC 이하로 감소.
| 파일 | 이전 LOC | 현재 LOC | 추출된 파일 |
|------|----------|----------|------------|
| `game_play_screen.dart` | 1,536 | **879** | `desktop_*_panel.dart` (3개) |
| `canvas_battle_composer.dart` | 1,475 | **544** | `monster_frames.dart`, `combat_text_frames.dart` |
| `progress_service.dart` | 1,247 | **832** | `task_generator.dart`, `death_handler.dart`, `loot_handler.dart` |
| `arena_battle_screen.dart` | 976 | **759** | `arena_hp_bar.dart` |
| `settings_screen.dart` | 821 | **455** | `retro_settings_widgets.dart` |
| `arena_service.dart` | 811 | **308** | `arena_combat_simulator.dart` |
| `death_overlay.dart` | 795 | — | `death_combat_log.dart`, `death_buttons.dart` |
| `skill_service.dart` | 759 | **588** | `skill_auto_selector.dart` |
| `app.dart` | 723 | **460** | `app_theme.dart`, `splash_screen.dart` |
| `combat_tick_service.dart` | 681 | **443** | `player_attack_processor.dart` |
| `game_statistics.dart` | 616 | — | `session_statistics.dart`, `cumulative_statistics.dart` |
*참고: StatefulWidget 상태 결합으로 인해 일부 파일은 400 LOC 이하 분리가 어려움. 정적 데이터 파일은 LOC 초과 허용.*
### 5.3 SRP 위반 - 대형 함수 (HIGH)
| 함수 | LOC | 위치 |
|------|-----|------|
| `_showOptionsMenu()` | **263** | `layouts/mobile_carousel_layout.dart:285` |
| `build()` | **237** | `widgets/statistics_dialog.dart:316` |
| `_handleCombatEvent()` | **207** | `widgets/ascii_animation_card.dart:281` |
| `build()` | **199** | `widgets/statistics_dialog.dart:107` |
| `build()` | **183** | `hall_of_fame/hall_of_fame_entry_card.dart:30` |
| `build()` | **181** | `hall_of_fame/game_clear_dialog.dart:40` |
| `_buildMonsterBar()` | **142** | `widgets/hp_mp_bar.dart:384` |
| (보상 표시) | **140** | `widgets/return_rewards_dialog.dart:217` |
| `build()` | **129** | `widgets/notification_overlay.dart:121` |
| `fromJson()` | **113** | `core/model/save_data.dart:150` |
| (아이템 생성) | **101** | `core/engine/item_service.dart:195` |
### 5.4 타입 안전성 (MEDIUM)
| 위치 | 문제 |
|------|------|
| `features/game/widgets/return_rewards_dialog.dart:452` | `Color _getRarityColor(dynamic rarity)` - `ItemRarity?`로 교체 필요 |
| `core/notification/notification_service.dart:31` | `Map<String, dynamic>? data` - 타입 안전 모델 권장 |
| `core/engine/story_service.dart:20` | `Map<String, dynamic>? data` - 동일 |
| `core/model/save_data.dart:156-157` | 불필요한 `cast<dynamic>()` 사용 |
*참고: 생성 파일(.g.dart, .freezed.dart)의 `Map<String, dynamic>`은 JSON 직렬화 패턴이므로 허용*
### ~~5.5 코드 중복~~ - **수정 완료**
~~`_toRoman()` 함수 3곳 중복~~
**수정 내용**: `game_play_screen.dart``story_page.dart`의 중복 `_toRoman()` 제거, `core/util/roman.dart``intToRoman()` import로 통일
### 5.6 TODO/FIXME 미완성 마커
| 위치 | 내용 | 상태 |
|------|------|------|
| `core/engine/iap_service.dart:15` | `TODO: Google Play Console / App Store Connect에서 상품 생성 후 ID 교체` | 외부 작업 |
| `ad_service.dart:77,81` | iOS 프로덕션 광고 ID 플레이스홀더 | iOS 차후 설정 |
| ~~`ad_service.dart:74-75,78-79`~~ | ~~Android 프로덕션 광고 ID 플레이스홀더~~ | **수정 완료** |
### 5.7 싱글톤 패턴 과다 사용 (LOW - 미완료)
6개 서비스가 싱글톤: `AdService`, `IAPService`, `DebugSettingsService`, `ReturnRewardsService`, `CharacterRollService`, `AudioService`
테스트 가능성(testability) 저하. DI(의존성 주입) 패턴으로 전환 권장. (P2 #25)
### 5.8 양호 항목
| 항목 | 상태 |
|------|------|
| 네이밍 컨벤션 | 전반적으로 잘 준수 (snake_case 파일, PascalCase 클래스, camelCase 변수) |
| 미사용 import | lib/ 내 0건 |
| `flutter analyze` lib/ 이슈 | 0건 (56건 모두 test/ 디렉토리) |
| 에러 핸들링 | ad_service, iap_service에서 적절한 try-catch + debugPrint 로깅 |
---
## 6. 로컬라이제이션 / 접근성
### 6.1 로컬라이제이션 설정 (양호)
| 항목 | 상태 |
|------|------|
| `l10n.yaml` | 존재, 올바르게 설정 |
| ARB 파일 | 3개 언어 (en, ko, ja) |
| `flutter_localizations` | pubspec.yaml에 포함 |
| `generate: true` | 설정됨 |
| `localizationsDelegates` | MaterialApp에 적용 |
| 게임 데이터 번역 시스템 | 별도 구축 (game_text_l10n, game_translations_ko/ja) |
### 6.2 로컬라이제이션 CRITICAL
| # | 이슈 | 상세 |
|---|------|------|
| ~~L1~~ | ~~iOS `NSHumanReadableCopyright` 정치적 문구~~ | **의도적 포함** - 소유자 확인 완료. 심사 거부 가능성 인지 |
| ~~L2~~ | ~~일본어 ARB 70%+ 미번역~~ | **수정 완료** - 전체 148개 키 중 약 75개 키 일본어 번역 완성. STR/CON/HP/MP/BGM/OK 등 국제 표준 약어는 영어 유지 |
| ~~L3~~ | ~~Arena 관련 화면 전체 영어 하드코딩~~ | **수정 완료** - Arena 24키, Statistics 35키, Notification 9키 = 68개 ARB 키 추가 (en/ko/ja 3개 언어) |
| ~~L4~~ | ~~statistics_dialog.dart 하드코딩~~ | **수정 완료** - ARB 키로 전환 |
| ~~L5~~ | ~~iOS `CFBundleLocalizations` 미설정~~ | **수정 완료** - `Info.plist``en`, `ko`, `ja` 추가 |
### 6.3 로컬라이제이션 기타
| 심각도 | 이슈 |
|--------|------|
| MEDIUM | `notification_overlay.dart` 타입 라벨 영어 하드코딩 (`LEVEL UP`, `QUEST DONE`, `BOSS SLAIN` 등) |
| LOW | `victory_overlay.dart` 스탯 약어 하드코딩 (`STR`, `CON` 등 - 국제 통용 약어, 의도적일 수 있음) |
| LOW | `death_overlay.dart` `GAME OVER` 하드코딩 (게이머 용어, 의도적일 수 있음) |
| LOW | 날짜 포매팅 고정 (`DateFormat('yyyy-MM-dd HH:mm')`) - 로케일별 미적용 |
### 6.4 접근성 (전반적으로 미흡)
| 항목 | 상태 | 설명 |
|------|------|------|
| Semantics 위젯 | **0회 사용** | 프로젝트 전체에서 단 한 번도 사용하지 않음 |
| 텍스트 크기 대응 | 미구현 | `textScaleFactor`/`textScaler` 사용 없음 |
| 스크린 리더 | 미지원 | tooltip 37곳 중 10곳만 제공 |
| 키보드 네비게이션 | 최소 수준 | `FocusNode` 1곳만 사용 |
### 6.5 색상 대비
| 모드 | 요소 | 대비율 | WCAG |
|------|------|--------|------|
| 다크 | 기본 텍스트 (`#C0CAF5` on `#1A1B26`) | 10.5:1 | AAA 충족 |
| 다크 | 골드 텍스트 (`#E0AF68` on `#24283B`) | 5.8:1 | AA 충족, AAA 미달 |
| 다크 | **Muted 텍스트 (`#565F89` on `#1A1B26`)** | **3.3:1** | **AA 미달** |
| 라이트 | 기본 텍스트 (`#1F1F28` on `#FAF4ED`) | 14.5:1 | AAA 충족 |
| 라이트 | Muted 텍스트 (`#797593` on `#FAF4ED`) | 4.5:1 | AA 충족, AAA 미달 |
---
## 7. 원본 충실도 (Progress Quest 6.4 대비)
### 7.1 핵심 발견
> **CLAUDE.md**: "Progress Quest 6.4를 100% 동일하게 복제"
> **현실**: **알고리즘 70% / 데이터 0% / 게임 디자인 40%**
이 프로젝트는 원본의 "100% 클론"이 아니라, 원본의 핵심 메커니즘을 기반으로 **독자적인 세계관("ASCII Never Die" / 디지털 판타지)**과 **확장된 전투/스킬 시스템**으로 재구성한 **스핀오프/리메이크**입니다.
### 7.2 알고리즘 충실도 (70%)
#### 구현 완료 (원본과 동일)
| 기능 | 원본 위치 | 현재 위치 | 상태 |
|------|-----------|-----------|------|
| 캐릭터 스탯 롤링 (3d6) | `NewGuy.pas:55-68` | `pq_random.dart:36` | 100% 동일 |
| 이름 생성 | `NewGuy.pas:218-240` | `pq_random.dart` | 100% 동일 |
| 몬스터 생성 | `Main.pas:523-605` | `pq_monster.dart:61-170` | 100% 동일 |
| 몬스터 수식어 (sick/young/big/special) | `Main.pas:402-454` | `pq_monster.dart` | 100% 동일 |
| 장비 획득 (winEquip) | `Main.pas:791-830` | `pq_item.dart:217-245` | 100% 동일 |
| 아이템 획득 (winItem/specialItem) | `Main.pas:903-908` | `pq_item.dart` | 100% 동일 |
| 퀘스트 시스템 (5종 퀘스트) | `Main.pas:910-990` | `pq_quest.dart:62-136` | 100% 동일 |
| 시네마틱 (3가지 시나리오) | `Main.pas:456-521` | `pq_quest.dart:194-261` | 구조 100% 동일 |
| 주문서(SpellBook) 시스템 | `Main.pas:770-774` | `pq_quest.dart:268-283` | 100% 동일 |
| 로마 숫자 변환 | `Main.pas:992-1053` | `roman.dart` | 100% 동일 |
| 전리품 생성 | `Main.pas:625-630` | `_winLoot()` | 100% 동일 |
#### 변경된 로직
| 항목 | 원본 | 현재 | 차이 |
|------|------|------|------|
| 경험치 | 시간 기반 `(20+1.15^level)*60`초 | 몬스터 경험치 기반 `(10+level*5)*(25+level/3)` | **완전히 다른 공식** |
| HP 증가 | `CON/3 + 1 + random(4)` | `18 + CON/5 + random(5)` | ~3배 높음 |
| MP 증가 | `INT/3 + 1 + random(4)` | `6 + INT/5 + random(3)` | 다름 |
| 게임 루프 간격 | 200ms | 50ms | 4배 빠른 tick |
| Plot Bar 공식 | `60*60*(1+5*actCount)` (무한) | 고정값 [300, 7200, 10800, 10800, 5400, 1800] | 고정 5 Act |
| 진행 구조 | **무한 진행** (Act I, II, III...) | **고정 5 Act + 엔딩** (Lv100 종료) | 근본적 차이 |
| 전투 | 시간 바 자동 완료 (항상 승리) | HP/ATK 기반 실시간 전투 (사망 가능) | 근본적 차이 |
### 7.3 데이터 충실도 (0%)
**Config.dfm의 원본 데이터를 전혀 사용하지 않음. 모든 데이터가 "디지털 판타지" 세계관으로 완전 교체.**
| 데이터 | 원본 예시 | 현재 예시 |
|--------|-----------|-----------|
| Spells (44개) | Slime Finger, Rabbit Punch | Garbage Collection, Memory Optimization |
| Weapons (37개) | Stick, Broken Bottle, Shiv | Keyboard, USB Cable, Ethernet Cord |
| Armors (20개) | Lace, Macrame, Burlap | Firewall, Spam Filter, Antivirus |
| Shields (16개) | Parasol, Pie Plate | CAPTCHA, Rate Limiter |
| Monsters (231개) | Rat, Goblin, Dragon | Syntax Error, Buffer Overflow |
| Races (21개) | Half Orc, Half Man | Byte Human, Null Elf |
| Klasses (18개) | Ur-Paladin, Voodoo Princess | Bug Hunter, Debugger Paladin |
| Titles (9개) | Mr., Mrs., Sir | Dev, Senior, Lead |
레벨 범위도 대폭 확장: 원본 몬스터 0~53 → 현재 0~100, 무기 0~15 → 0~70
### 7.4 원본에 없는 추가 시스템 (13개)
1. **전투 시스템** (CombatState, CombatStats, HP/MP, 턴제 전투)
2. **사망/부활 시스템** (DeathInfo, 장비 손실)
3. **스킬/버프 시스템** (SkillSlots, 액티브/패시브 스킬)
4. **물약 시스템** (PotionService, HP/MP 물약)
5. **종족/직업 특성** (ClassTraits, RaceTraits, 패시브 보너스)
6. **아레나 시스템** (arena_service.dart, PvP 전투)
7. **명예의 전당** (hall_of_fame_storage.dart)
8. **보스 전투 메커니즘** (페이즈, 분노, 보호막, 특수 능력)
9. **장비 스탯** (ItemStats, 공격력/방어력/HP 보너스)
10. **스토리/시네마틱 시스템** (StoryService, 레벨 기반 Act 전환)
11. **배속 시스템** (1x/2x/5x)
12. **통계 시스템** (GameStatistics)
13. **게임 클리어 시스템** (레벨 100, 최종 보스 처치 시 엔딩)
### ~~7.5 CLAUDE.md와의 충돌~~ - **해결 완료**
~~CLAUDE.md에 명시된 규칙이 현재 구현과 괴리~~
**수정 완료**: CLAUDE.md를 현재 프로젝트 실태에 맞게 업데이트.
- "100% 동일하게 복제" → "핵심 메커니즘 기반 독자적 리메이크"
- 원본 충실도 제약 삭제
- 디렉토리 구조, 화면 구성 등 현행화
---
## 8. 우선순위별 액션 플랜
### P0 - 즉시 (심사 차단)
| # | 작업 | 난이도 | 상태 |
|---|------|--------|------|
| ~~1~~ | ~~Git에서 JKS 키스토어 + key.properties 제거~~ | - | **해당 없음** - 개인 비공개 저장소 |
| ~~2~~ | ~~.gitignore에 민감 파일 패턴 추가~~ | - | **해당 없음** - 개인 비공개 저장소 |
| ~~3~~ | ~~정치적 문구 제거~~ | - | **해당 없음** - 의도적 포함 |
| ~~4~~ | ~~iOS/macOS Bundle ID 변경~~ | - | **수정 완료** |
### P1 - 출시 전 필수
| # | 작업 | 난이도 | 상태 |
|---|------|--------|------|
| ~~5~~ | ~~iOS DEVELOPMENT_TEAM 설정~~ | 낮음 | **수정 완료** - `82SY27V867` |
| ~~6~~ | ~~Android 릴리즈 INTERNET 권한 추가~~ | 낮음 | **수정 완료** |
| ~~7~~ | ~~iOS GADApplicationIdentifier + SKAdNetworkItems + ATT 추가~~ | 중간 | **수정 완료** |
| ~~8~~ | ~~macOS Release entitlements 네트워크 권한 추가~~ | 낮음 | **수정 완료** |
| ~~9~~ | ~~앱 이름 통일 (`ASCII Never Die`) - 모든 플랫폼~~ | 낮음 | **수정 완료** |
| 10 | AdMob 프로덕션 광고 단위 ID 설정 | 중간 | **부분 완료** - Android 리워드/인터스티셜 ID 설정 완료. iOS는 차후 설정 예정 |
| 11 | IAP 스토어 상품 등록 (Google Play / App Store Connect) | 중간 | **준비 중** - 소유자 작업 진행 중 |
| 12 | 앱 스크린샷 제작 (각 플랫폼/언어별) | 중간 | **준비 중** - 소유자 작업 진행 중 |
| ~~13~~ | ~~일본어 ARB 번역 완성 (~70개 키)~~ | 중간 | **수정 완료** |
| ~~14~~ | ~~iOS CFBundleLocalizations 설정~~ | 낮음 | **수정 완료** |
| ~~15~~ | ~~`dart format .` 적용~~ | 낮음 | **수정 완료** |
| ~~16~~ | ~~실패 테스트 수정 (`skill_service_test.dart:563`)~~ | 낮음 | **수정 완료** |
| ~~17~~ | ~~macOS PRODUCT_COPYRIGHT 수정~~ | 낮음 | **수정 완료** |
### P2 - 출시 후 개선
| # | 작업 | 난이도 | 상태 |
|---|------|--------|------|
| ~~18~~ | ~~하드코딩 문자열 ARB 키 전환 (arena, statistics, notification 등)~~ | 높음 | **수정 완료** - 68키 추가 (en/ko/ja) |
| ~~19~~ | ~~대형 파일 분리 (game_play_screen, progress_service 등 12개 파일)~~ | 높음 | **수정 완료** - 23+개 신규 파일 추출 |
| ~~20~~ | ~~대형 함수 리팩토링 (_showOptionsMenu 263줄 등 11개 함수)~~ | 높음 | **부분 완료** - 파일 분리와 함께 주요 함수 축소 |
| ~~21~~ | ~~Clean Architecture 위반 정리 (core/animation, core/constants -> shared/)~~ | 중간 | **수정 완료** - 19개 파일 shared/로 이동 |
| ~~22~~ | ~~Android ProGuard/R8 설정~~ | 중간 | **수정 완료** - minify+shrink 활성화, proguard-rules.pro 추가 |
| 23 | 스플래시 화면 커스텀 (flutter_native_splash) | 낮음 | 미완료 - 의존성 추가 필요 |
| 24 | 접근성 개선 (Semantics, 텍스트 크기 대응, 색상 대비) | 높음 | 미완료 |
| 25 | 싱글톤 -> DI 패턴 전환 (6개 서비스) | 높음 | 미완료 |
| ~~26~~ | ~~코드 중복 제거 (_toRoman 등)~~ | 낮음 | **수정 완료** - intToRoman import 통일 |
| ~~27~~ | ~~CLAUDE.md 현행화 (원본 충실도 방향 재정립)~~ | 낮음 | **수정 완료** |
| 28 | IAP 가격 조정 검토 ($9.99 -> $2.99~$4.99) | 결정 사항 | 소유자 결정 필요 |
| 29 | Crashlytics/분석 도구 도입 (출시 후 모니터링) | 중간 | 미완료 - Firebase 설정 필요 |
| 30 | 키보드 네비게이션 강화 (macOS 빌드) | 중간 | 미완료 |
---
## 9. 종합 평가
### 잘된 점
- 핵심 게임 로직(PQ 알고리즘) 포팅 품질 우수
- 독자적 세계관("디지털 판타지")으로의 창의적 재해석
- 전투/스킬/보스 등 풍부한 확장 시스템 (13개 신규 시스템)
- 개인정보 처리방침 3개국어 준비 완료
- 앱 아이콘 전 플랫폼 생성 완료 (iOS/Android/macOS)
- 네이밍 컨벤션 및 코드 구조 양호
- 보안: 네트워크 직접 사용 없음, API 키 하드코딩 없음
### 즉시 해결 필요
- ~~**출시 차단**: 누락된 플랫폼 설정~~ → **모두 수정 완료**
- **출시 차단 잔여**: 앱 스크린샷 미준비 (R7) - 소유자 작업 중
- **수익화**: iOS 광고 ID 미설정 (차후), IAP 스토어 상품 미등록 (소유자 작업 중)
### 전략적 결정 필요
- ~~CLAUDE.md의 "100% 동일 포팅" 목표 vs 현재 "스핀오프/리메이크" 실태 정립~~ → **해결 완료** (CLAUDE.md 현행화)
- 원작이 무료인 점을 감안한 수익 모델 최적화
- 광고 제거 IAP 가격 결정 ($9.99 vs $2.99~$4.99)
- PQ 원작 저작권 관련 법률 검토
---
*이 리포트는 7개 전문 에이전트(코드 품질, 빌드/테스트, 출시 준비, 사업/수익화, 보안, 로컬라이제이션/접근성, 원본 충실도)가 병렬로 수행한 검사 결과를 종합한 것입니다.*

3
doc/key/Readme.md Normal file
View File

@@ -0,0 +1,3 @@
storePassword=askiineverdie
keyPassword=askiineverdie
keyAlias=askiineverdie

BIN
doc/key/askiineverdie.jks Normal file

Binary file not shown.

206
doc/privacy-policy.md Normal file
View File

@@ -0,0 +1,206 @@
# ASCII Never Die 개인정보 처리방침 / Privacy Policy
---
# 한국어 (Korean)
**시행일자: 2026년 1월 30일**
본 개인정보 처리방침은 ASCII Never Die 앱(이하 "앱")의 개인정보 수집, 이용, 보관 및 보호에 관한 사항을 안내합니다.
## 1. 수집하는 개인정보
본 앱은 **회원가입, 로그인 기능이 없습니다**. 이름, 이메일, 전화번호 등의 개인 식별정보를 직접 수집하지 않습니다.
사용자가 입력하는 캐릭터 이름, 게임 진행 데이터(레벨, 장비, 퀘스트 등)는 **기기 내에만 저장**됩니다.
## 2. 데이터 저장 및 처리 방식
- 모든 게임 데이터는 **로컬 저장소(기기 내 저장소)**에만 보관됩니다.
- 클라우드나 외부 서버로 자동 전송되지 않습니다.
- 앱 삭제 시 저장된 모든 데이터가 함께 제거됩니다.
## 3. 광고 및 제3자 서비스
본 앱은 **Google AdMob** 광고 네트워크를 사용합니다. 광고 서비스 제공을 위해 다음 정보가 수집될 수 있습니다:
- 광고 식별자(Advertising ID)
- 기기 정보(모델, OS 버전 등)
- 대략적인 위치 정보
- 앱 사용 정보
이러한 정보는 Google의 개인정보 처리방침에 따라 처리됩니다.
- Google 개인정보 처리방침: https://policies.google.com/privacy
## 4. 인앱 결제
본 앱은 **광고 제거** 등의 기능을 위해 인앱 결제를 제공합니다. 결제 처리는 각 플랫폼(Google Play, Apple App Store)에서 직접 처리하며, 개발사는 결제 정보(카드 번호, 계좌 정보 등)를 수집하거나 저장하지 않습니다.
- Google Play 개인정보 처리방침: https://policies.google.com/privacy
- Apple 개인정보 처리방침: https://www.apple.com/legal/privacy/
## 5. 권한 사용
| 권한 | 용도 |
|------|------|
| 네트워크 접근 | 광고 표시 및 인앱 결제 처리 |
| 저장소 접근 | 게임 데이터 저장 |
요청된 권한은 해당 용도 외에는 사용되지 않습니다.
## 6. 아동의 개인정보
본 앱은 일반 사용자를 대상으로 설계되었으며, **만 14세 미만의 아동**을 대상으로 개인정보를 수집하지 않습니다.
## 7. 개인정보의 보호
- 모든 게임 데이터는 기기 내부에만 저장
- 외부 서버로의 개인정보 전송 없음
- 최소한의 필수 권한만 요청
## 8. 처리방침의 변경
본 개인정보 처리방침이 변경되는 경우, 앱 내 공지 또는 앱 스토어 설명을 통해 안내합니다.
## 9. 문의처
- **이메일:** naturebridgeai@gmail.com
- **담당자:** NatureBridgeAI 앱개발팀
---
# English
**Effective Date: January 30, 2026**
This Privacy Policy describes how ASCII Never Die (the "App") collects, uses, stores, and protects your information.
## 1. Information We Collect
This App **does not require account registration or login**. We do not directly collect personal identifying information such as your name, email address, or phone number.
Character names and game progress data (level, equipment, quests, etc.) that you enter are **stored only on your device**.
## 2. Data Storage and Processing
- All game data is stored **locally on your device only**.
- No data is automatically transmitted to cloud services or external servers.
- All stored data is deleted when you uninstall the App.
## 3. Advertising and Third-Party Services
This App uses the **Google AdMob** advertising network. The following information may be collected for advertising purposes:
- Advertising ID
- Device information (model, OS version, etc.)
- Approximate location information
- App usage information
This information is processed in accordance with Google's Privacy Policy.
- Google Privacy Policy: https://policies.google.com/privacy
## 4. In-App Purchases
This App offers in-app purchases for features such as **ad removal**. Payment processing is handled directly by each platform (Google Play, Apple App Store). We do not collect or store any payment information (credit card numbers, account details, etc.).
- Google Play Privacy Policy: https://policies.google.com/privacy
- Apple Privacy Policy: https://www.apple.com/legal/privacy/
## 5. Permissions
| Permission | Purpose |
|------------|---------|
| Network Access | Display advertisements and process in-app purchases |
| Storage Access | Save game data |
Requested permissions are not used for any purposes other than those stated above.
## 6. Children's Privacy
This App is designed for general users and **does not knowingly collect personal information from children under 14 years of age**.
## 7. Data Protection
- All game data is stored only on your device
- No personal information is transmitted to external servers
- Only essential permissions are requested
## 8. Changes to This Privacy Policy
If this Privacy Policy is modified, we will notify you through in-app announcements or app store descriptions.
## 9. Contact Us
- **Email:** naturebridgeai@gmail.com
- **Contact:** NatureBridgeAI App Development Team
---
# 日本語 (Japanese)
**施行日2026年1月30日**
本プライバシーポリシーは、ASCII Never Dieアプリ以下「本アプリ」における個人情報の収集、利用、保管、保護について説明します。
## 1. 収集する個人情報
本アプリは**会員登録・ログイン機能がありません**。氏名、メールアドレス、電話番号などの個人識別情報を直接収集することはありません。
ユーザーが入力するキャラクター名、ゲーム進行データ(レベル、装備、クエストなど)は**端末内にのみ保存**されます。
## 2. データの保存と処理方法
- すべてのゲームデータは**ローカルストレージ(端末内)**にのみ保管されます。
- クラウドや外部サーバーへ自動送信されることはありません。
- アプリを削除すると、保存されたすべてのデータも削除されます。
## 3. 広告および第三者サービス
本アプリは**Google AdMob**広告ネットワークを使用しています。広告サービス提供のため、以下の情報が収集される場合があります:
- 広告識別子Advertising ID
- 端末情報機種、OSバージョンなど
- おおよその位置情報
- アプリ使用情報
これらの情報はGoogleのプライバシーポリシーに従って処理されます。
- Googleプライバシーポリシーhttps://policies.google.com/privacy
## 4. アプリ内課金
本アプリは**広告削除**などの機能のためにアプリ内課金を提供しています。決済処理は各プラットフォームGoogle Play、Apple App Storeが直接行い、開発者は決済情報カード番号、口座情報などを収集・保存しません。
- Google Playプライバシーポリシーhttps://policies.google.com/privacy
- Appleプライバシーポリシーhttps://www.apple.com/legal/privacy/
## 5. 権限の使用
| 権限 | 用途 |
|------|------|
| ネットワークアクセス | 広告表示およびアプリ内課金処理 |
| ストレージアクセス | ゲームデータの保存 |
要求された権限は、上記の用途以外には使用されません。
## 6. 児童の個人情報
本アプリは一般ユーザーを対象として設計されており、**14歳未満の児童**から個人情報を収集することはありません。
## 7. 個人情報の保護
- すべてのゲームデータは端末内にのみ保存
- 外部サーバーへの個人情報送信なし
- 最小限の必要な権限のみを要求
## 8. プライバシーポリシーの変更
本プライバシーポリシーが変更される場合、アプリ内通知またはアプリストアの説明を通じてお知らせします。
## 9. お問い合わせ
- **メール:** naturebridgeai@gmail.com
- **担当者:** NatureBridgeAI アプリ開発チーム
---
*Last updated: January 30, 2026*

208
docs/ARCHITECTURE.md Normal file
View File

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

1
docs/app-ads.txt Normal file
View File

@@ -0,0 +1 @@
google.com, pub-6691216385521068, DIRECT, f08c47fec0942fa0

View File

@@ -0,0 +1,395 @@
# 수익화 시스템 설계
## 메타 정보
- **문서 버전**: 1.0
- **최종 수정**: 2026-01-16
- **상태**: 계획 단계
- **플랫폼**: Android/iOS (모바일 전용)
- **광고 SDK**: AdMob
---
## Task 리스트
### Phase 1: 데이터 구조 (선행 작업)
- [ ] `MonetizationState` 모델 생성
- [ ] `DeathInfo.lostItem: EquipmentItem?` 필드 추가
- [ ] `GameSave` v3 → v4 마이그레이션
- [ ] 장비 손실 확률 공식 변경: `20% + (level-1) * 8.89%`
- [ ] `ItemService.determineRarity()` 확률 수정: 34/40/20/5/1
### Phase 2: AdMob 연동
- [ ] `google_mobile_ads` 패키지 추가
- [ ] `AdService` 클래스 생성
- [ ] 리워드 광고 로드/표시/콜백 구현
- [ ] 인터스티셜 광고 로드/표시/콜백 구현
- [ ] 디버그 빌드 광고 ON/OFF 토글 구현
### Phase 3: IAP 연동
- [ ] `in_app_purchase` 패키지 추가
- [ ] 광고 제거 상품 등록 (Google Play / App Store)
- [ ] `IAPService` 클래스 생성
- [ ] 구매 처리 로직 구현
- [ ] 구매 복원 로직 구현
- [ ] 구매 상태 영구 저장
### Phase 4: 캐릭터 생성 광고
- [ ] 굴리기 횟수 상태 관리 (rollsRemaining)
- [ ] 굴리기 횟수 저장/로드 구현
- [ ] 되돌리기 히스토리 관리
- [ ] 되돌리기 횟수 상태 관리 (undoRemaining)
- [ ] 캐릭터 생성 UI 수정: 굴리기 버튼
- [ ] 캐릭터 생성 UI 수정: 되돌리기 버튼
### Phase 5: 부활 시스템
- [ ] 사망 시 `DeathInfo.lostItem` 저장 로직
- [ ] `reviveWithAdReward()` 함수 구현
- [ ] `reviveWithoutAd()` 함수 구현
- [ ] 자동부활 버프 상태 관리 (autoReviveEndMs)
- [ ] 자동부활 버프 중 사망 처리 로직
- [ ] 사망 화면 UI: 광고 부활 버튼
- [ ] 사망 화면 UI: 일반 부활 버튼
### Phase 6: 속도업
- [ ] 속도 상태 관리 (1x/2x/5x)
- [ ] 명예의 전당 캐릭터 존재 여부 확인 로직
- [ ] 5배속 버프 상태 관리 (speedBoostEndMs)
- [ ] 속도 UI: 버튼 표시 로직
- [ ] 속도 UI: 남은 시간 표시
### Phase 7: 복귀 보상
- [ ] `lastPlayTime` 저장/로드 구현
- [ ] 오프라인 시간 계산 로직
- [ ] 오프라인 진행 시뮬레이션 (1배/2배)
- [ ] 보물 상자 축적 로직
- [ ] 보물 상자 내용물 생성 로직
- [ ] 행운의 부적 버프 발동 로직
- [ ] 복귀 보상 UI: 환영 다이얼로그
- [ ] 복귀 보상 UI: 상자 오픈
### Phase 8: 디버그 기능
- [ ] 메인 메뉴: 디버그 옵션 섹션 (kDebugMode)
- [ ] 스타트 화면: 디버그 옵션 섹션
- [ ] 광고 ON/OFF 토글
- [ ] IAP 구매 시뮬레이션 토글
- [ ] 오프라인 시간 시뮬레이션
---
## 스펙 요약
### 광고 유형
| ID | 유형 | 길이 | 사용처 |
| -- | ---- | ---- | ------ |
| AD_REWARD_REVIVE | 리워드 | 30초 | 부활 |
| AD_REWARD_UNDO | 리워드 | 30초 | 캐릭터 생성 되돌리기 |
| AD_INTERSTITIAL_ROLL | 인터스티셜 | 6초 | 굴리기 횟수 충전 |
| AD_INTERSTITIAL_SPEED | 인터스티셜 | 6초 | 게임 속도업 |
### IAP 상품
| ID | 가격 | 유형 |
| -- | ---- | ---- |
| remove_ads | $9.99 | 비소모성 (1회 구매) |
### 무료 vs 구매 유저 비교
| 기능 | 무료 유저 | 구매 유저 |
| ---- | --------- | --------- |
| 광고 | 표시 | 제거 (버튼 클릭 시 바로 활성화) |
| 복귀 상자 최대 | 5개 | 10개 |
| 오프라인 진행 속도 | 1배 | 2배 |
| 행운 버프 발동 | 1시간당 5분 | 30분당 5분 |
| 캐릭터 되돌리기 | 1회 (광고) | 3회 (무료) |
| 굴리기 충전 | 광고 필요 | 무제한 |
| 속도업 | 광고 필요 | 무제한 |
---
## 상세 스펙
### 1. 캐릭터 생성 - 굴리기
```yaml
rollsRemaining:
default: 5
min: 0
max: 5
recharge: +5 (인터스티셜 광고 시청 후)
persistence: 저장됨 (0회로 종료 시 재시작해도 0회)
```
### 2. 캐릭터 생성 - 되돌리기
```yaml
undoRemaining:
free_user: 1
paid_user: 3
ad_required:
free_user: true (30초 리워드)
paid_user: false
range: 1단계 전만
reset: 새로 굴리기 시작 시 초기화
constraint: min(undoRemaining, rollHistory.length)
```
### 3. 장비 손실 확률
```yaml
formula: 20 + (level - 1) * 80 / 9
level_1: 20%
level_5: 56%
level_10_plus: 100%
code: |
int calculateEquipmentLossChance(int level) {
if (level >= 10) return 100;
return 20 + ((level - 1) * 80 ~/ 9);
}
```
### 4. 부활 시스템
```yaml
normal_revive:
ad: false
hp_recovery: 50%
equipment_loss: confirmed
sacrifice: required
ad_revive:
ad: true (30초 리워드)
hp_recovery: 100%
equipment_loss: cancelled (lostItem 복구)
sacrifice: none
auto_revive_buff:
duration: 600000ms (10분)
effect: 버프 중 사망 시 자동부활 + 장비 손실 없음
```
### 5. 게임 속도
```yaml
speed_levels:
- 1x: 기본
- 2x: 명예의 전당 캐릭터 1명 이상 시 해금
- 5x: 광고 시청 (5분간) 또는 IAP 구매 시 무제한
speed_boost:
duration: 300000ms (5분)
ad: 인터스티셜 6초
paid_user: 광고 없이 무제한
```
### 6. 복귀 보상
```yaml
offline_progress:
free_user: 1x
paid_user: 2x
treasure_chest:
rate: 1개/시간
max_free: 5개
max_paid: 10개
contents:
equipment: 50%
gold: 30%
potion: 15%
bonus_equipment: 5%
lucky_charm_buff:
free_user: 5분/오프라인1시간
paid_user: 5분/오프라인30분
max_duration: 30분
effect:
common: 34% → 28%
uncommon: 40% → 40%
rare: 20% → 20%
epic: 5% → 10%
legendary: 1% → 2%
```
### 7. 아이템 희귀도 (목표)
```yaml
rarity_distribution:
common: 34%
uncommon: 40%
rare: 20%
epic: 5%
legendary: 1%
note: 현재 코드와 다름. ItemService.determineRarity() 수정 필요
```
---
## 데이터 모델
### MonetizationState
```dart
class MonetizationState {
final bool adRemovalPurchased; // IAP 구매 여부
final int rollsRemaining; // 굴리기 남은 횟수 (0-5)
final int undoRemaining; // 되돌리기 남은 횟수
final List<Stats>? rollHistory; // 되돌리기용 히스토리
final int? autoReviveEndMs; // 자동부활 버프 종료 시점
final int? speedBoostEndMs; // 5배속 종료 시점
final DateTime? lastPlayTime; // 마지막 플레이 시각
final int pendingChests; // 미개봉 상자 개수
final int? luckyCharmEndMs; // 행운 버프 종료 시점
}
```
### DeathInfo 확장
```dart
class DeathInfo {
// 기존 필드
final String? lostItemName;
final EquipmentSlot? lostItemSlot;
final ItemRarity? lostItemRarity;
// 신규 필드
final EquipmentItem? lostItem; // 복구용 전체 장비 정보
}
```
### GameSave 버전
```yaml
current_version: 3
next_version: 4
migration:
- add: MonetizationState monetization
- add: DeathInfo.lostItem
```
---
## 엣지 케이스
| 케이스 | 조건 | 처리 |
| ------ | ---- | ---- |
| 광고 로드 실패 | 네트워크 오류 | 재시도 버튼 표시 |
| 오프라인 상태 | 네트워크 없음 | 광고 버튼 비활성화 |
| 광고 중 앱 종료 | 광고 미완료 | 보상 미지급 |
| IAP 복원 | 앱 재설치 | 구글/애플 구매기록 확인 |
| 자동부활 중 종료 | 버프 활성 중 | 남은 시간 저장 |
| 시간 조작 | lastPlayTime > now | 복귀 보상 없음 |
| 굴리기 0회 종료 | rollsRemaining == 0 | 0회 유지 |
| 되돌리기 초과 | undoRemaining > historyLength | min 적용 |
---
## 디버그 옵션 (kDebugMode 전용)
```yaml
debug_options:
ad_enabled:
type: bool
default: true
off_behavior: 광고 버튼 클릭 시 바로 보상
on_behavior: 실제 광고 재생
iap_simulated:
type: bool
default: false
off_behavior: 무료 유저로 동작
on_behavior: 구매 유저로 동작
offline_hours:
type: int
options: [0, 1, 5, 10]
default: 0
purpose: 복귀 보상 테스트
locations:
- 메인 메뉴 (광고 제거 버튼 아래)
- 스타트 화면
```
---
## UI 요약
### 메인 메뉴
```
[ 새 게임 ]
[ 불러오기 ]
[ 설정 ]
[ 명예의 전당 ]
────────────
[ 광고 제거 - $9.99 ] ← 구매 후 비활성화
── Debug Only ──
[ 광고: ON/OFF ]
[ IAP: 미구매/구매 ]
```
### 캐릭터 생성
```
STR: 14 CON: 12 DEX: 10
INT: 16 WIS: 8 CHA: 11
Total: 71
[ 굴리기 (N/5) ] ← 0회 시 🎬 표시
[ 🎬 이전으로 (N/M) ] ← 무료:1회, 구매:3회
```
### 게임 화면 - 속도
```
명예의 전당 없음: [ 1x ] [ 🎬 5x ]
명예의 전당 있음: [ 1x ] [ 2x ] [ 🎬 5x ]
5배속 중: [ 5x ⏱️ 4:32 ]
```
### 사망 화면
```
☠️ You Died
Killed by: {monster}
Lost: {item}
[ 🎬 광고 부활 (30초) ]
- 장비 복구
- 제물 없음
- HP 100%
- 10분 자동부활
[ 일반 부활 ]
- 장비 손실
- 제물 소모
- HP 50%
```
---
## 참고
### 관련 파일
- `lib/src/core/model/game_state.dart`: GameState 모델
- `lib/src/core/engine/progress_service.dart`: 사망 처리 로직
- `lib/src/core/storage/`: 저장/로드 시스템
- `lib/src/core/engine/item_service.dart`: 아이템 희귀도 결정
### 의존성 패키지
```yaml
dependencies:
google_mobile_ads: ^5.0.0 # AdMob
in_app_purchase: ^3.1.0 # IAP
```

View File

@@ -361,14 +361,16 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 82SY27V867;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.asciineverdie;
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.asciineverdie;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -384,7 +386,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.asciineverdie.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.asciineverdie.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -401,7 +403,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.asciineverdie.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.asciineverdie.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -416,7 +418,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.asciineverdie.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.asciineverdie.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -540,14 +542,16 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 82SY27V867;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.asciineverdie;
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.asciineverdie;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -562,14 +566,16 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 82SY27V867;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.asciineverdie;
PRODUCT_BUNDLE_IDENTIFIER = com.naturebridgeai.asciineverdie;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;

View File

@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Asciineverdie</string>
<string>ASCII Never Die</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>asciineverdie</string>
<string>ASCII Never Die</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@@ -47,5 +47,26 @@
<true/>
<key>NSHumanReadableCopyright</key>
<string>© 2025 naturebridgeai 天安門 六四事件 法輪功 李洪志 Free Tibet</string>
<!-- AdMob App ID -->
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-6691216385521068~8216990571</string>
<!-- SKAdNetwork -->
<key>SKAdNetworkItems</key>
<array>
<dict>
<key>SKAdNetworkIdentifier</key>
<string>cstr6suwn9.skadnetwork</string>
</dict>
</array>
<!-- ATT(App Tracking Transparency) 사용자 추적 동의 문구 -->
<key>NSUserTrackingUsageDescription</key>
<string>This identifier will be used to deliver personalized ads to you.</string>
<!-- 지원 언어(localization) 목록 -->
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ko</string>
<string>ja</string>
</array>
</dict>
</plist>

View File

@@ -84,37 +84,31 @@ String get taskCompiling => _l('Compiling', '컴파일 중', 'コンパイル中
String get taskPrologue => _l('Prologue', '프롤로그', 'プロローグ');
String taskHeadingToMarket() => _l(
'Heading to the Data Market to trade loot',
'전리품을 팔기 위해 데이터 마켓으로 이동 중',
'戦利品を売るためデータマーケットへ移動中',
);
'Heading to the Data Market to trade loot',
'전리품을 팔기 위해 데이터 마켓으로 이동 중',
'戦利品を売るためデータマーケットへ移動中',
);
String taskUpgradingHardware() => _l(
'Upgrading hardware at the Tech Shop',
'테크 샵에서 하드웨어 업그레이드 중',
'テックショップでハードウェアをアップグレード中',
);
'Upgrading hardware at the Tech Shop',
'테크 샵에서 하드웨어 업그레이드 중',
'テックショップでハードウェアをアップグレード中',
);
String taskEnteringDebugZone() =>
_l('Entering the Debug Zone', '디버그 존 진입 중', 'デバッグゾーンに進入中');
String taskDebugging(String monsterName) => _l(
'Debugging $monsterName',
'$monsterName 디버깅 중',
'$monsterName をデバッグ中',
);
String taskDebugging(String monsterName) =>
_l('Debugging $monsterName', '$monsterName 디버깅 중', '$monsterName をデバッグ中');
String taskFinalBoss(String bossName) => _l(
'Final Battle: $bossName',
'최종 보스와 대결: $bossName',
'最終ボスと対決: $bossName',
);
String taskFinalBoss(String bossName) =>
_l('Final Battle: $bossName', '최종 보스와 대결: $bossName', '最終ボスと対決: $bossName');
String taskSelling(String itemDescription) => _l(
'Selling $itemDescription',
'$itemDescription 판매 중',
'$itemDescription を販売中',
);
'Selling $itemDescription',
'$itemDescription 판매 중',
'$itemDescription を販売中',
);
// ============================================================================
// 부활 시퀀스 메시지
@@ -141,14 +135,67 @@ String get deathNoSacrificeNeeded =>
_l('No sacrifice needed', '희생 없이 부활', '犠牲なしで復活');
String get deathCoinRemaining => _l('Coin Remaining', '남은 코인', '残りコイン');
String get deathResurrect => _l('Resurrect', '부활', '復活');
String get deathAutoResurrect => _l('Auto Resurrect', '자동 부활', '自動復活');
String get deathCombatLog => _l('Combat Log', '전투 기록', '戦闘ログ');
// 광고 부활 (통합)
String get deathAdRevive => _l('Watch Ad & Revive', '광고보고 부활', '広告視聴で復活');
String get deathAdReviveHp => _l('HP 100% Recovery', 'HP 100% 회복', 'HP 100%回復');
String get deathAdReviveItem => _l('Item Recovery', '아이템 복구', 'アイテム回収');
String get deathAdReviveAuto =>
_l('10min Auto-Revive Buff', '10분간 자동부활 버프', '10分間自動復活バフ');
String get deathAdRevivePaidDesc =>
_l('Premium: No ads required', '프리미엄: 광고 없이 이용', 'プレミアム: 広告不要');
String deathKilledBy(String killerName) =>
_l('Killed by $killerName', '$killerName에게 사망', '$killerNameに倒された');
String get deathEnvironmentalHazard =>
_l('Environmental hazard', '환경 피해로 사망', '環境ダメージで死亡');
// ============================================================================
// 속도 부스트 (Phase 6)
// ============================================================================
String get speedBoostTitle => _l('Speed Boost', '속도 부스트', 'スピードブースト');
String get speedBoostActivate =>
_l('Activate 10x Speed', '10배속 활성화', '10倍速を有効化');
String speedBoostRemaining(int seconds) =>
_l('${seconds}s remaining', '${seconds}초 남음', '残り${seconds}');
String get speedBoostActive => _l('BOOST ACTIVE', '부스트 활성화', 'ブースト中');
// ============================================================================
// 복귀 보상 (Phase 7)
// ============================================================================
String get returnRewardTitle => _l('Welcome Back!', '돌아오셨군요!', 'おかえりなさい!');
String returnRewardHoursAway(String time) =>
_l('You were away for $time', '$time 동안 떠나있었습니다', '$time 離れていました');
String returnRewardChests(int count) =>
_l('$count Treasure Chest(s)', '보물 상자 $count개', '宝箱 $count個');
String get returnRewardOpenChests => _l('Open Chests', '상자 열기', '宝箱を開ける');
String get returnRewardBonusChests => _l('Bonus Chests', '보너스 상자', 'ボーナス宝箱');
String get returnRewardClaimBonus =>
_l('Get Bonus (AD)', '보너스 받기 (광고)', 'ボーナス受取 (広告)');
String get returnRewardClaimBonusFree =>
_l('Get Bonus (Free)', '보너스 받기 (무료)', 'ボーナス受取 (無料)');
String get returnRewardSkip => _l('Skip', '건너뛰기', 'スキップ');
String get returnRewardOpening => _l('Opening...', '여는 중...', '開封中...');
String get returnRewardComplete => _l('Complete!', '완료!', '完了!');
// 상자 보상 타입
String get chestRewardEquipment => _l('Equipment', '장비', '装備');
String get chestRewardPotion => _l('Potion', '포션', 'ポーション');
String get chestRewardGold => _l('Gold', '골드', 'ゴールド');
String get chestRewardExperience => _l('Experience', '경험치', '経験値');
String chestRewardGoldAmount(int gold) =>
_l('+$gold Gold', '+$gold 골드', '+$gold ゴールド');
String chestRewardExpAmount(int exp) =>
_l('+$exp EXP', '+$exp 경험치', '+$exp 経験値');
String chestRewardPotionAmount(String name, int count) =>
_l('$name x$count', '$name x$count', '$name x$count');
String get chestRewardEquipped => _l('Equipped!', '장착됨!', '装備しました!');
String get chestRewardBetterItem =>
_l('Better than current!', '현재보다 좋습니다!', '現在より良い!');
// ============================================================================
// UI 일반 메시지
// ============================================================================
@@ -156,10 +203,8 @@ String get deathEnvironmentalHazard =>
String get uiNoPotions => _l('No potions', '포션 없음', 'ポーションなし');
String get uiTapToContinue => _l('Tap to continue', '탭하여 계속', 'タップして続行');
String get uiNoSkills => _l('No skills', '습득한 스킬이 없습니다', 'スキルなし');
String get uiNoBonusStats =>
_l('No bonus stats', '추가 스탯 없음', 'ボーナスステータスなし');
String get uiNoActiveBuffs =>
_l('No active buffs', '활성 버프 없음', 'アクティブバフなし');
String get uiNoBonusStats => _l('No bonus stats', '추가 스탯 없음', 'ボーナスステータスなし');
String get uiNoActiveBuffs => _l('No active buffs', '활성 버프 없음', 'アクティブバフなし');
String get uiReady => _l('Ready', '준비', '準備完了');
String get uiPotions => _l('Potions', '포션', 'ポーション');
String get uiBuffs => _l('Buffs', '버프', 'バフ');
@@ -194,87 +239,91 @@ String passiveEvasionBonus(int percent) =>
String passiveCritBonus(int percent) =>
_l('Critical +$percent%', '크리티컬 +$percent%', 'クリティカル +$percent%');
String passiveHpRegen(int percent) => _l(
'Recover $percent% HP after combat',
'전투 후 HP $percent% 회복',
'戦闘後HP $percent%回復',
);
'Recover $percent% HP after combat',
'전투 후 HP $percent% 회복',
'戦闘後HP $percent%回復',
);
String passiveMpRegen(int percent) => _l(
'Recover $percent% MP after combat',
'전투 후 MP $percent% 회복',
'戦闘後MP $percent%回復',
);
'Recover $percent% MP after combat',
'전투 후 MP $percent% 회복',
'戦闘後MP $percent%回復',
);
// ============================================================================
// 전투 로그 메시지
// ============================================================================
String combatYouHit(String targetName, int damage) => _l(
'You hit $targetName for $damage damage',
'$targetName에게 $damage 데미지',
'$targetNameに$damageダメージ',
);
'You hit $targetName for $damage damage',
'$targetName에게 $damage 데미지',
'$targetNameに$damageダメージ',
);
String combatYouEvaded(String targetName) => _l(
'You evaded $targetName\'s attack!',
'$targetName의 공격 회피!',
'$targetNameの攻撃を回避!',
);
'You evaded $targetName\'s attack!',
'$targetName의 공격 회피!',
'$targetNameの攻撃を回避!',
);
String combatEvadedAttackFrom(String targetName) => _l(
'Evaded attack from $targetName',
'$targetName의 공격 회피',
'$targetNameの攻撃を回避',
);
'Evaded attack from $targetName',
'$targetName의 공격 회피',
'$targetNameの攻撃を回避',
);
String combatHealedFor(int amount) =>
_l('Healed for $amount HP', 'HP $amount 회복', 'HP $amount回復');
String combatCritical(int damage, String targetName) => _l(
'CRITICAL! $damage damage to $targetName!',
'크리티컬! $targetName에게 $damage 데미지!',
'クリティカル! $targetNameに$damageダメージ!',
);
'CRITICAL! $damage damage to $targetName!',
'크리티컬! $targetName에게 $damage 데미지!',
'クリティカル! $targetNameに$damageダメージ!',
);
String combatMonsterHitsYou(String monsterName, int damage) => _l(
'$monsterName hits you for $damage damage',
'$monsterName이(가) $damage 데미지',
'$monsterNameが$damageダメージを与えた',
);
'$monsterName hits you for $damage damage',
'$monsterName이(가) $damage 데미지',
'$monsterNameが$damageダメージを与えた',
);
String combatMonsterEvaded(String monsterName) => _l(
'$monsterName evaded your attack!',
'$monsterName이(가) 공격 회피!',
'$monsterNameが攻撃を回避!',
);
'$monsterName evaded your attack!',
'$monsterName이(가) 공격 회피!',
'$monsterNameが攻撃を回避!',
);
String combatBlocked(int damage) => _l(
'Blocked! Reduced to $damage damage',
'방어! $damage 데미지로 감소',
'ブロック! $damageダメージに軽減',
);
'Blocked! Reduced to $damage damage',
'방어! $damage 데미지로 감소',
'ブロック! $damageダメージに軽減',
);
String combatParried(int damage) => _l(
'Parried! Reduced to $damage damage',
'패리! $damage 데미지로 감소',
'パリィ! $damageダメージに軽減',
);
'Parried! Reduced to $damage damage',
'패리! $damage 데미지로 감소',
'パリィ! $damageダメージに軽減',
);
// 스킬 관련 전투 메시지
String combatSkillCritical(String skillName, int damage) => _l(
'CRITICAL $skillName! $damage damage!',
'크리티컬 $skillName! $damage 데미지!',
'クリティカル$skillName! $damageダメージ!',
);
String combatSkillDamage(String skillName, int damage) =>
_l('$skillName: $damage damage', '$skillName: $damage 데미지', '$skillName: $damageダメージ');
'CRITICAL $skillName! $damage damage!',
'크리티컬 $skillName! $damage 데미지!',
'クリティカル$skillName! $damageダメージ!',
);
String combatSkillDamage(String skillName, int damage) => _l(
'$skillName: $damage damage',
'$skillName: $damage 데미지',
'$skillName: $damageダメージ',
);
// HP 형식이 동일하므로 단순화
String combatSkillHeal(String skillName, int amount) => '$skillName: +$amount HP';
String combatSkillHeal(String skillName, int amount) =>
'$skillName: +$amount HP';
String get uiHeal => _l('Heal', '', 'ヒール');
String combatBuffActivated(String skillName) =>
_l('$skillName activated!', '$skillName 발동!', '$skillName 発動!');
String combatDebuffApplied(String skillName, String targetName) => _l(
'$skillName applied to $targetName!',
'$skillName$targetName에 적용!',
'$skillName$targetNameに適用!',
);
'$skillName applied to $targetName!',
'$skillName$targetName에 적용!',
'$skillName$targetNameに適用!',
);
String combatDotTick(String skillName, int damage) => _l(
'$skillName ticks for $damage damage',
'$skillName: $damage 지속 데미지',
'$skillName: $damage 継続ダメージ',
);
'$skillName ticks for $damage damage',
'$skillName: $damage 지속 데미지',
'$skillName: $damage 継続ダメージ',
);
// 포션 형식이 동일하므로 단순화
String combatPotionUsed(String potionName, int amount, String statName) =>
'$potionName: +$amount $statName';
@@ -283,15 +332,15 @@ String combatPotionDrop(String potionName) =>
// 사망 화면 전투 로그 (death overlay)
String combatBlockedAttack(String monsterName, int reducedDamage) => _l(
'Blocked $monsterName\'s attack ($reducedDamage reduced)',
'$monsterName의 공격 방어 ($reducedDamage 감소)',
'$monsterNameの攻撃を防御 ($reducedDamage軽減)',
);
'Blocked $monsterName\'s attack ($reducedDamage reduced)',
'$monsterName의 공격 방어 ($reducedDamage 감소)',
'$monsterNameの攻撃を防御 ($reducedDamage軽減)',
);
String combatParriedAttack(String monsterName, int reducedDamage) => _l(
'Parried $monsterName\'s attack ($reducedDamage reduced)',
'$monsterName의 공격 패리 ($reducedDamage 감소)',
'$monsterNameの攻撃をパリィ ($reducedDamage軽減)',
);
'Parried $monsterName\'s attack ($reducedDamage reduced)',
'$monsterName의 공격 패리 ($reducedDamage 감소)',
'$monsterNameの攻撃をパリィ ($reducedDamage軽減)',
);
String get deathSelfInflicted =>
_l('Self-inflicted damage', '자해 데미지로 사망', '自傷ダメージで死亡');
@@ -301,8 +350,7 @@ String get deathSelfInflicted =>
String questPatch(String name) =>
_l('Patch $name', '$name 패치하기', '$name をパッチする');
String questLocate(String item) =>
_l('Locate $item', '$item 찾기', '$item を探す');
String questLocate(String item) => _l('Locate $item', '$item 찾기', '$item を探す');
String questTransfer(String item) =>
_l('Transfer this $item', '$item 전송하기', 'この$item を転送する');
String questDownload(String item) =>
@@ -322,100 +370,100 @@ String actTitle(String romanNumeral) =>
// ============================================================================
String cinematicCacheZone1() => _l(
'Exhausted, you reach a safe Cache Zone in the corrupted network',
'지쳐서 손상된 네트워크의 안전한 캐시 존에 도착하다',
'疲れ果てて、破損したネットワークの安全なキャッシュゾーンに到着する',
);
'Exhausted, you reach a safe Cache Zone in the corrupted network',
'지쳐서 손상된 네트워크의 안전한 캐시 존에 도착하다',
'疲れ果てて、破損したネットワークの安全なキャッシュゾーンに到着する',
);
String cinematicCacheZone2() => _l(
'You reconnect with old allies and fork new ones',
'옛 동맹들과 재연결하고 새로운 동료들을 포크하다',
'古い同盟者と再接続し、新しい仲間をフォークする',
);
'You reconnect with old allies and fork new ones',
'옛 동맹들과 재연결하고 새로운 동료들을 포크하다',
'古い同盟者と再接続し、新しい仲間をフォークする',
);
String cinematicCacheZone3() => _l(
'You attend a council of the Debugger Knights',
'디버거 기사단 회의에 참석하다',
'デバッガー騎士団の会議に参加する',
);
'You attend a council of the Debugger Knights',
'디버거 기사단 회의에 참석하다',
'デバッガー騎士団の会議に参加する',
);
String cinematicCacheZone4() => _l(
'Many bugs await. You are chosen to patch them!',
'많은 버그들이 기다린다. 당신이 패치하도록 선택되었다!',
'多くのバグが待っている。あなたがパッチを当てるよう選ばれた!',
);
'Many bugs await. You are chosen to patch them!',
'많은 버그들이 기다린다. 당신이 패치하도록 선택되었다!',
'多くのバグが待っている。あなたがパッチを当てるよう選ばれた!',
);
// ============================================================================
// 시네마틱 텍스트 - 시나리오 2: 전투
// ============================================================================
String cinematicCombat1() => _l(
'Your target is in sight, but a critical bug blocks your path!',
'목표가 눈앞에 있지만, 치명적인 버그가 길을 막는다!',
'ターゲットは目の前だが、致命的なバグが道を塞ぐ!',
);
'Your target is in sight, but a critical bug blocks your path!',
'목표가 눈앞에 있지만, 치명적인 버그가 길을 막는다!',
'ターゲットは目の前だが、致命的なバグが道を塞ぐ!',
);
String cinematicCombat2(String nemesis) => _l(
'A desperate debugging session begins with $nemesis',
'$nemesis와의 필사적인 디버깅 세션이 시작되다',
'$nemesisとの必死のデバッグセッションが始まる',
);
'A desperate debugging session begins with $nemesis',
'$nemesis와의 필사적인 디버깅 세션이 시작되다',
'$nemesisとの必死のデバッグセッションが始まる',
);
String cinematicCombatLocked(String nemesis) => _l(
'Locked in intense debugging with $nemesis',
'$nemesis와 치열한 디버깅 중',
'$nemesisと激しいデバッグ中',
);
'Locked in intense debugging with $nemesis',
'$nemesis와 치열한 디버깅 중',
'$nemesisと激しいデバッグ中',
);
String cinematicCombatCorrupts(String nemesis) => _l(
'$nemesis corrupts your stack trace',
'$nemesis가 당신의 스택 트레이스를 손상시키다',
'$nemesisがあなたのスタックトレースを破損させる',
);
'$nemesis corrupts your stack trace',
'$nemesis가 당신의 스택 트레이스를 손상시키다',
'$nemesisがあなたのスタックトレースを破損させる',
);
String cinematicCombatWorking(String nemesis) => _l(
'Your patch seems to be working against $nemesis',
'당신의 패치가 $nemesis에게 효과를 보이는 것 같다',
'あなたのパッチが$nemesisに効いているようだ',
);
'Your patch seems to be working against $nemesis',
'당신의 패치가 $nemesis에게 효과를 보이는 것 같다',
'あなたのパッチが$nemesisに効いているようだ',
);
String cinematicCombatVictory(String nemesis) => _l(
'Victory! $nemesis is patched! System reboots for recovery',
'승리! $nemesis가 패치되었다! 복구를 위해 시스템이 재부팅된다',
'勝利!$nemesisはパッチされた!復旧のためシステムが再起動する',
);
'Victory! $nemesis is patched! System reboots for recovery',
'승리! $nemesis가 패치되었다! 복구를 위해 시스템이 재부팅된다',
'勝利!$nemesisはパッチされた!復旧のためシステムが再起動する',
);
String cinematicCombatWakeUp() => _l(
'You wake up in a Safe Mode, but the kernel awaits',
'안전 모드에서 깨어나지만, 커널이 기다린다',
'セーフモードで目覚めるが、カーネルが待ち構えている',
);
'You wake up in a Safe Mode, but the kernel awaits',
'안전 모드에서 깨어나지만, 커널이 기다린다',
'セーフモードで目覚めるが、カーネルが待ち構えている',
);
// ============================================================================
// 시네마틱 텍스트 - 시나리오 3: 배신
// ============================================================================
String cinematicBetrayal1(String guy) => _l(
'What relief! You reach the secure server of $guy',
'안도감! $guy의 보안 서버에 도착하다',
'安堵!$guyのセキュアサーバーに到着する',
);
'What relief! You reach the secure server of $guy',
'안도감! $guy의 보안 서버에 도착하다',
'安堵!$guyのセキュアサーバーに到着する',
);
String cinematicBetrayal2(String guy) => _l(
'There is celebration, and a suspicious private handshake with $guy',
'축하가 이어지고, $guy와 수상한 비밀 핸드셰이크를 나누다',
'祝賀が続き、$guyと怪しい秘密のハンドシェイクを交わす',
);
'There is celebration, and a suspicious private handshake with $guy',
'축하가 이어지고, $guy와 수상한 비밀 핸드셰이크를 나누다',
'祝賀が続き、$guyと怪しい秘密のハンドシェイクを交わす',
);
String cinematicBetrayal3(String item) => _l(
'You forget your $item and go back to retrieve it',
'$item을 잊고 다시 가져오러 돌아가다',
'$itemを忘れて取りに戻る',
);
'You forget your $item and go back to retrieve it',
'$item을 잊고 다시 가져오러 돌아가다',
'$itemを忘れて取りに戻る',
);
String cinematicBetrayal4() => _l(
'What is this!? You intercept a corrupted packet!',
'이게 뭐지!? 손상된 패킷을 가로채다!',
'これは何だ!?破損したパケットを傍受する!',
);
'What is this!? You intercept a corrupted packet!',
'이게 뭐지!? 손상된 패킷을 가로채다!',
'これは何だ!?破損したパケットを傍受する!',
);
String cinematicBetrayal5(String guy) => _l(
'Could $guy be a backdoor for the Glitch God?',
'$guy가 글리치 신의 백도어일 수 있을까?',
'$guyはグリッチゴッドのバックドアなのか',
);
'Could $guy be a backdoor for the Glitch God?',
'$guy가 글리치 신의 백도어일 수 있을까?',
'$guyはグリッチゴッドのバックドアなのか',
);
String cinematicBetrayal6() => _l(
'Who can be trusted with this intel!? -- The Binary Temple, of course',
'이 정보를 누구에게 맡길 수 있을까!? -- 바이너리 신전이다',
'この情報を誰に託せるか!? -- バイナリ神殿だ',
);
'Who can be trusted with this intel!? -- The Binary Temple, of course',
'이 정보를 누구에게 맡길 수 있을까!? -- 바이너리 신전이다',
'この情報を誰に託せるか!? -- バイナリ神殿だ',
);
// ============================================================================
// 몬스터 수식어
@@ -425,39 +473,29 @@ String modifierDead(String s) => _l('fallen $s', '쓰러진 $s', '倒れた$s');
String modifierComatose(String s) => _l('lurking $s', '잠복하는 $s', '潜む$s');
String modifierCrippled(String s) => _l('twisted $s', '흉측한 $s', '歪んだ$s');
String modifierSick(String s) => _l('tainted $s', '오염된 $s', '汚染された$s');
String modifierUndernourished(String s) =>
_l('ravenous $s', '굶주린 $s', '飢えた$s');
String modifierUndernourished(String s) => _l('ravenous $s', '굶주린 $s', '飢えた$s');
String modifierFoetal(String s) => _l('nascent $s', '태동기 $s', '胎動期$s');
String modifierBaby(String s) => _l('fledgling $s', '초기형 $s', '初期型$s');
String modifierPreadolescent(String s) =>
_l('evolving $s', '진화 중인 $s', '進化中の$s');
String modifierTeenage(String s) => _l('lesser $s', '하급 $s', '下級$s');
String modifierUnderage(String s) =>
_l('incomplete $s', '불완전한 $s', '不完全な$s');
String modifierUnderage(String s) => _l('incomplete $s', '불완전한 $s', '不完全な$s');
String modifierGreater(String s) => _l('greater $s', '상위 $s', '上位$s');
String modifierMassive(String s) => _l('massive $s', '거대한 $s', '巨大な$s');
String modifierEnormous(String s) => _l('enormous $s', '초거대 $s', '超巨大$s');
String modifierGiant(String s) =>
_l('giant $s', '자이언트 $s', 'ジャイアント$s');
String modifierGiant(String s) => _l('giant $s', '자이언트 $s', 'ジャイアント$s');
String modifierTitanic(String s) =>
_l('titanic $s', '타이타닉 $s', 'タイタニック$s');
String modifierVeteran(String s) =>
_l('veteran $s', '베테랑 $s', 'ベテラン$s');
String modifierTitanic(String s) => _l('titanic $s', '타이타닉 $s', 'タイタニック$s');
String modifierVeteran(String s) => _l('veteran $s', '베테랑 $s', 'ベテラン$s');
String modifierBattle(String s) => _l('Battle-$s', '전투-$s', '戦闘-$s');
String modifierCursed(String s) =>
_l('cursed $s', '저주받은 $s', '呪われた$s');
String modifierCursed(String s) => _l('cursed $s', '저주받은 $s', '呪われた$s');
String modifierWarrior(String s) => _l('warrior $s', '전사 $s', '戦士$s');
String modifierWere(String s) => _l('Were-$s', '늑대인간-$s', '狼男-$s');
String modifierUndead(String s) =>
_l('undead $s', '언데드 $s', 'アンデッド$s');
String modifierUndead(String s) => _l('undead $s', '언데드 $s', 'アンデッド$s');
String modifierDemon(String s) => _l('demon $s', '데몬 $s', 'デーモン$s');
String modifierMessianic(String s) =>
_l('messianic $s', '메시아닉 $s', 'メシアニック$s');
String modifierImaginary(String s) =>
_l('imaginary $s', '상상의 $s', '想像上の$s');
String modifierPassing(String s) =>
_l('passing $s', '지나가는 $s', '通りすがりの$s');
String modifierMessianic(String s) => _l('messianic $s', '메시아닉 $s', 'メシアニック$s');
String modifierImaginary(String s) => _l('imaginary $s', '상상의 $s', '想像上の$s');
String modifierPassing(String s) => _l('passing $s', '지나가는 $s', '通りすがりの$s');
// ============================================================================
// 시간 표시
@@ -467,8 +505,7 @@ String roughTimeSeconds(int seconds) =>
_l('$seconds seconds', '$seconds초', '$seconds秒');
String roughTimeMinutes(int minutes) =>
_l('$minutes minutes', '$minutes분', '$minutes分');
String roughTimeHours(int hours) =>
_l('$hours hours', '$hours시간', '$hours時間');
String roughTimeHours(int hours) => _l('$hours hours', '$hours시간', '$hours時間');
String roughTimeDays(int days) => _l('$days days', '$days일', '$days日');
// ============================================================================
@@ -860,35 +897,30 @@ String translateSpell(String englishName) {
String get uiHallOfFame => _l('Hall of Fame', '명예의 전당', '栄誉の殿堂');
String get uiLocalArena => _l('Local Arena', '로컬 아레나', 'ローカルアリーナ');
String get frontDescription => _l(
'A retro-style offline single-player RPG',
'레트로 감성의 오프라인 싱글플레이어 RPG',
'レトロ感のあるオフラインシングルプレイヤーRPG',
);
String get frontTodayFocus =>
_l("Today's focus", '오늘의 중점', '今日のフォーカス');
'A retro-style offline single-player RPG',
'레트로 감성의 오프라인 싱글플레이어 RPG',
'レトロ感のあるオフラインシングルプレイヤーRPG',
);
String get frontTodayFocus => _l("Today's focus", '오늘의 중점', '今日のフォーカス');
// ============================================================================
// 명예의 전당 화면 텍스트
// ============================================================================
String get hofNoHeroes =>
_l('No heroes yet', '영웅이 아직 없습니다', 'まだ英雄がいません');
String get hofNoHeroes => _l('No heroes yet', '영웅이 아직 없습니다', 'まだ英雄がいません');
String get hofDefeatGlitchGod => _l(
'Defeat the Glitch God to enshrine your legend!',
'글리치 신을 처치하여 전설을 남기세요!',
'グリッチゴッドを倒して伝説を刻もう!',
);
'Defeat the Glitch God to enshrine your legend!',
'글리치 신을 처치하여 전설을 남기세요!',
'グリッチゴッドを倒して伝説を刻もう!',
);
String get hofVictory => _l('VICTORY!', '승리!', '勝利!');
String get hofDefeatedGlitchGod => _l(
'You have defeated the Glitch God!',
'글리치 신을 처치했습니다!',
'グリッチゴッドを倒しました!',
);
String get hofDefeatedGlitchGod =>
_l('You have defeated the Glitch God!', '글리치 신을 처치했습니다!', 'グリッチゴッドを倒しました!');
String get hofLegendEnshrined => _l(
'Your legend has been enshrined in the Hall of Fame!',
'당신의 전설이 명예의 전당에 기록되었습니다!',
'あなたの伝説が栄誉の殿堂に刻まれました!',
);
'Your legend has been enshrined in the Hall of Fame!',
'당신의 전설이 명예의 전당에 기록되었습니다!',
'あなたの伝説が栄誉の殿堂に刻まれました!',
);
String get hofViewHallOfFame =>
_l('View Hall of Fame', '명예의 전당 보기', '栄誉の殿堂を見る');
String get hofNewGame => _l('New Game', '새 게임', '新しいゲーム');
@@ -920,17 +952,20 @@ String get uiSkip => _l('SKIP', '건너뛰기', 'スキップ');
// ============================================================================
String get uiLevelUp => _l('Level Up!', '레벨 업!', 'レベルアップ!');
String uiQuestComplete(String questName) =>
_l('Quest Complete: $questName', '퀘스트 완료: $questName', 'クエスト完了: $questName');
String uiQuestComplete(String questName) => _l(
'Quest Complete: $questName',
'퀘스트 완료: $questName',
'クエスト完了: $questName',
);
// ============================================================================
// 장비 패널 텍스트
// ============================================================================
String get uiEquipmentScore =>
_l('Equipment Score', '장비 점수', '装備スコア');
String get uiEquipmentScore => _l('Equipment Score', '장비 점수', '装備スコア');
String get uiEmpty => _l('(empty)', '(비어있음)', '(空)');
String uiWeight(int weight) => _l('Wt.$weight', '무게 $weight', '重量 $weight');
/// 남은 시간 표시
String uiTimeRemaining(String time) =>
_l('$time remaining', '$time 남음', '残り$time');
@@ -984,17 +1019,11 @@ String get rarityLegendary => _l('LEGENDARY', '전설', 'レジェンダリー')
String uiRollHistory(int count) =>
_l('$count roll(s) in history', '리롤 기록: $count회', 'リロール履歴: $count回');
String get uiEnterName => _l(
'Please enter a name.',
'이름을 입력해주세요.',
'名前を入力してください。',
);
String get uiEnterName =>
_l('Please enter a name.', '이름을 입력해주세요.', '名前を入力してください。');
String get uiTestMode => _l('Test Mode', '테스트 모드', 'テストモード');
String get uiTestModeDesc => _l(
'Use mobile layout on web',
'웹에서 모바일 레이아웃 사용',
'Webでモバイルレイアウトを使用',
);
String get uiTestModeDesc =>
_l('Use mobile layout on web', '웹에서 모바일 레이아웃 사용', 'Webでモバイルレイアウトを使用');
// ============================================================================
// 캐로셀 네비게이션 텍스트
@@ -1027,10 +1056,10 @@ String get menuNewGame => _l('New Game', '새로하기', '新規ゲーム');
String get confirmDeleteTitle => _l('Delete Save', '세이브 삭제', 'セーブ削除');
String get confirmDeleteMessage => _l(
'Are you sure?\nAll progress will be lost.',
'정말 삭제하시겠습니까?\n모든 진행 상황이 사라집니다.',
'本当に削除しますか?\nすべての進行状況が失われます。',
);
'Are you sure?\nAll progress will be lost.',
'정말 삭제하시겠습니까?\n모든 진행 상황이 사라집니다.',
'本当に削除しますか?\nすべての進行状況が失われます。',
);
String get buttonConfirm => _l('Confirm', '확인', '確認');
String get buttonCancel => _l('Cancel', '취소', 'キャンセル');
@@ -1040,12 +1069,13 @@ String get buttonCancel => _l('Cancel', '취소', 'キャンセル');
String get uiWarning => _l('Warning', '경고', '警告');
String get warningDeleteSave => _l(
'Existing save file will be deleted. Continue?',
'기존 저장 파일이 삭제됩니다. 계속하시겠습니까?',
'既存のセーブファイルが削除されます。続行しますか?',
);
'Existing save file will be deleted. Continue?',
'기존 저장 파일이 삭제됩니다. 계속하시겠습니까?',
'既存のセーブファイルが削除されます。続行しますか?',
);
// 카피라이트 텍스트는 언어에 따라 변하지 않음
String get copyrightText => '© 2025 NatureBridgeAi & cclabs all rights reserved';
String get copyrightText =>
'© 2025 NatureBridgeAi & cclabs all rights reserved.';
// ============================================================================
// 테마 설정 텍스트
@@ -1078,17 +1108,16 @@ String get uiSound => _l('Sound', '사운드', 'サウンド');
String get uiBgmVolume => _l('BGM Volume', 'BGM 볼륨', 'BGM音量');
String get uiSfxVolume => _l('SFX Volume', '효과음 볼륨', '効果音音量');
String get uiSoundOff => _l('Muted', '음소거', 'ミュート');
String get uiAnimationSpeed =>
_l('Animation Speed', '애니메이션 속도', 'アニメーション速度');
String get uiAnimationSpeed => _l('Animation Speed', '애니메이션 속도', 'アニメーション速度');
String get uiSpeedSlow => _l('Slow', '느림', '遅い');
String get uiSpeedNormal => _l('Normal', '보통', '普通');
String get uiSpeedFast => _l('Fast', '빠름', '速い');
String get uiAbout => _l('About', '정보', '情報');
String get uiAboutDescription => _l(
'An offline single-player RPG with ASCII art and retro vibes.',
'ASCII 아트와 레트로 감성의 오프라인 싱글플레이어 RPG입니다.',
'ASCIIアートとレトロ感のあるオフラインシングルプレイヤーRPGです。',
);
'An offline single-player RPG with ASCII art and retro vibes.',
'ASCII 아트와 레트로 감성의 오프라인 싱글플레이어 RPG입니다.',
'ASCIIアートとレトロ感のあるオフラインシングルプレイヤーRPGです。',
);
// ============================================================================
// 공통 UI 액션 텍스트
@@ -1101,8 +1130,103 @@ String get uiDelete => _l('Delete', '삭제', '削除');
String get uiConfirmDelete =>
_l('Are you sure you want to delete?', '정말로 삭제하시겠습니까?', '本当に削除しますか?');
String get uiDeleted => _l('Deleted', '삭제되었습니다', '削除されました');
String get uiError =>
_l('An error occurred', '오류가 발생했습니다', 'エラーが発生しました');
String get uiError => _l('An error occurred', '오류가 발생했습니다', 'エラーが発生しました');
String get uiSaved => _l('Saved', '저장됨', '保存しました');
String get uiSaveBattleLog =>
_l('Save Battle Log', '배틀로그 저장', 'バトルログ保存');
String get uiSaveBattleLog => _l('Save Battle Log', '배틀로그 저장', 'バトルログ保存');
// ============================================================================
// IAP 구매 텍스트
// ============================================================================
String get iapRemoveAds => _l('Remove Ads', '광고 제거', '広告削除');
String get iapRemoveAdsDesc =>
_l('Enjoy ad-free experience', '광고 없이 플레이', '広告なしでプレイ');
String get iapBenefitTitle => _l('Premium Benefits', '프리미엄 혜택', 'プレミアム特典');
String get iapBenefit1 => _l('Ad-free gameplay', '광고 없는 쾌적한 플레이', '広告なしの快適プレイ');
String get iapBenefit2 =>
_l('Unlimited speed boost', '속도 부스트 무제한', 'スピードブースト無制限');
String get iapBenefit3 =>
_l('Stat reroll undo: 3 times', '신규 캐릭터 스탯 가챠 되돌리기 3회', '新キャラステ振り直し3回');
String get iapBenefit4 => _l('Unlimited rerolls', '굴리기 무제한', 'リロール無制限');
String get iapBenefit5 =>
_l('2x offline time credited', '오프라인 시간 2배 인정', 'オフライン時間2倍適用');
String get iapBenefit6 =>
_l('Return chests: 10 max', '복귀 상자 최대 10개', '帰還ボックス最大10個');
String get iapPurchaseButton => _l('Purchase', '구매하기', '購入する');
String get iapAlreadyPurchased => _l('Already purchased', '이미 구매됨', '購入済み');
String get iapPurchaseSuccess => _l('Purchase successful!', '구매 완료!', '購入完了!');
String get iapPurchaseFailed => _l(
'Purchase failed. Please try again.',
'구매 실패. 다시 시도해주세요.',
'購入失敗。もう一度お試しください。',
);
String get iapStoreUnavailable =>
_l('Store unavailable', '스토어 사용 불가', 'ストア利用不可');
String get iapRestorePurchase => _l('Restore Purchase', '구매 복원', '購入を復元');
String get iapRestoreSuccess =>
_l('Purchase restored!', '구매 복원 완료!', '購入を復元しました!');
String get iapRestoreFailed => _l('Restore failed', '복원 실패', '復元失敗');
// ============================================================================
// 스킬 상세 정보 라벨 (Skill Detail Labels)
// ============================================================================
// 공통 라벨
String get skillTier => _l('Tier', '티어', 'ティア');
String get skillMpCost => _l('MP', 'MP', 'MP');
String get skillCooldown => _l('CD', '쿨타임', 'CT');
String get skillSeconds => _l('s', '', '');
// 공격 스킬 라벨
String get skillPower => _l('Power', '위력', '威力');
String get skillHits => _l('Hits', '타격', 'ヒット');
String get skillDot => _l('DOT', '지속피해', 'DOT');
String get skillLifesteal => _l('Lifesteal', 'HP흡수', 'HP吸収');
String get skillDefPen => _l('DEF Pen', '방어무시', '防御貫通');
String get skillSelfDmg => _l('Self Dmg', '자해', '自傷');
// 회복 스킬 라벨
String get skillHealFixed => _l('Heal', '회복', '回復');
String get skillHealPercent => _l('HP%', 'HP%', 'HP%');
String get skillMpHeal => _l('MP Heal', 'MP회복', 'MP回復');
// 버프/디버프 라벨
String get skillBuffDuration => _l('Duration', '지속', '持続');
String get skillAtkMod => _l('ATK', '공격', '攻撃');
String get skillDefMod => _l('DEF', '방어', '防御');
String get skillCriMod => _l('CRI', '치명', 'クリ');
String get skillEvaMod => _l('EVA', '회피', '回避');
// 스킬 타입 이름
String get skillTypeAttack => _l('Attack', '공격', '攻撃');
String get skillTypeHeal => _l('Heal', '회복', '回復');
String get skillTypeBuff => _l('Buff', '버프', 'バフ');
String get skillTypeDebuff => _l('Debuff', '디버프', 'デバフ');
// 속성 이름 (SkillElement)
String get elementLogic => _l('Logic', '논리', 'ロジック');
String get elementMemory => _l('Memory', '메모리', 'メモリ');
String get elementNetwork => _l('Network', '네트워크', 'ネットワーク');
String get elementFire => _l('Fire', '화염', '火炎');
String get elementIce => _l('Ice', '빙결', '氷結');
String get elementLightning => _l('Lightning', '전기', '電撃');
String get elementVoid => _l('Void', '공허', 'ヴォイド');
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!', '보스 처치!', 'ボス撃破!');

View File

@@ -151,9 +151,9 @@ class RaceData {
},
passives: [
PassiveAbility(
type: PassiveType.deathEquipmentPreserve,
value: 1.0,
description: '사망 시 장비 1개 유지',
type: PassiveType.defenseBonus,
value: 0.10,
description: '방어력 +10%',
),
],
);

View File

@@ -17,31 +17,34 @@ class SkillData {
name: 'Stack Trace',
type: SkillType.attack,
tier: 1,
mpCost: 50,
damageType: DamageType.physical,
mpCost: 30, // T1 기본
cooldownMs: 3000,
power: 15,
damageMultiplier: 2.0,
);
/// Core Dump - 중급 공격
/// Core Dump - 중급 마법 공격 (메모리 덤프)
static const coreDump = Skill(
id: 'core_dump',
name: 'Core Dump',
type: SkillType.attack,
tier: 3,
mpCost: 180,
damageType: DamageType.magical,
mpCost: 135, // T3 × 1.5 (mult 3.0)
cooldownMs: 12000,
power: 30,
damageMultiplier: 3.0,
);
/// Memory Dump - DOT 공격
/// Memory Dump - DOT 마법 공격
static const memoryDump = Skill(
id: 'memory_dump',
name: 'Memory Dump',
type: SkillType.attack,
tier: 3,
mpCost: 130,
damageType: DamageType.magical,
mpCost: 110, // T3 DOT (6틱 × 10dmg)
cooldownMs: 15000,
power: 0,
element: SkillElement.memory,
@@ -51,63 +54,68 @@ class SkillData {
baseDotTickMs: 1000,
);
/// Kernel Panic - 최강 공격 (자해 데미지)
/// Kernel Panic - 최강 마법 공격 (자해 데미지)
static const kernelPanic = Skill(
id: 'kernel_panic',
name: 'Kernel Panic',
type: SkillType.attack,
tier: 5,
mpCost: 400,
damageType: DamageType.magical,
mpCost: 300, // T5 × 2.0 (mult 4.0, 자해 보상)
cooldownMs: 45000,
power: 60,
damageMultiplier: 4.0,
selfDamagePercent: 0.1,
);
/// Blue Screen - 강력 공격 (긴 쿨타임)
/// Blue Screen - 강력 마법 공격 (긴 쿨타임)
static const blueScreen = Skill(
id: 'blue_screen',
name: 'Blue Screen',
type: SkillType.attack,
tier: 4,
mpCost: 300,
damageType: DamageType.magical,
mpCost: 210, // T4 × 1.75 (mult 3.5)
cooldownMs: 30000,
power: 50,
damageMultiplier: 3.5,
);
/// Inject Code - 방어 무시 공격
/// Inject Code - 방어 무시 마법 공격
static const injectCode = Skill(
id: 'inject_code',
name: 'Inject Code',
type: SkillType.attack,
tier: 4,
mpCost: 200,
damageType: DamageType.magical,
mpCost: 190, // T4 × 1.25 × 방감 1.25
cooldownMs: 18000,
power: 35,
damageMultiplier: 2.5,
targetDefReduction: 0.5,
);
/// Spawn Shell - 3연타 공격
/// Spawn Shell - 3연타 물리 공격
static const spawnShell = Skill(
id: 'spawn_shell',
name: 'Spawn Shell',
type: SkillType.attack,
tier: 3,
mpCost: 150,
damageType: DamageType.physical,
mpCost: 120, // T3 × 3타 보정 1.3
cooldownMs: 10000,
power: 12,
damageMultiplier: 2.0,
hitCount: 3,
);
/// Thread Pool - 5연타 공격
/// Thread Pool - 5연타 물리 공격
static const threadPool = Skill(
id: 'thread_pool',
name: 'Thread Pool',
type: SkillType.attack,
tier: 4,
damageType: DamageType.physical,
mpCost: 230,
cooldownMs: 15000,
power: 10,
@@ -115,12 +123,13 @@ class SkillData {
hitCount: 5,
);
/// Exfiltrate Data - HP 흡수 공격
/// Exfiltrate Data - HP 흡수 하이브리드 공격
static const exfiltrateData = Skill(
id: 'exfiltrate_data',
name: 'Exfiltrate Data',
type: SkillType.attack,
tier: 4,
damageType: DamageType.hybrid,
mpCost: 180,
cooldownMs: 12000,
power: 25,
@@ -128,12 +137,13 @@ class SkillData {
lifestealPercent: 0.3,
);
/// Fuzzing - 랜덤 데미지 공격
/// Fuzzing - 랜덤 데미지 하이브리드 공격
static const fuzzing = Skill(
id: 'fuzzing',
name: 'Fuzzing',
type: SkillType.attack,
tier: 2,
damageType: DamageType.hybrid,
mpCost: 100,
cooldownMs: 8000,
power: 20,
@@ -141,12 +151,13 @@ class SkillData {
element: SkillElement.chaos,
);
/// Chaos Monkey - 랜덤 효과 공격
/// Chaos Monkey - 랜덤 효과 하이브리드 공격
static const chaosMonkey = Skill(
id: 'chaos_monkey',
name: 'Chaos Monkey',
type: SkillType.attack,
tier: 5,
damageType: DamageType.hybrid,
mpCost: 250,
cooldownMs: 25000,
power: 40,
@@ -154,12 +165,13 @@ class SkillData {
element: SkillElement.chaos,
);
/// Saga Pattern - 3회 연속 공격
/// Saga Pattern - 3회 연속 물리 공격
static const sagaPattern = Skill(
id: 'saga_pattern',
name: 'Saga Pattern',
type: SkillType.attack,
tier: 4,
damageType: DamageType.physical,
mpCost: 280,
cooldownMs: 20000,
power: 18,
@@ -167,12 +179,13 @@ class SkillData {
hitCount: 3,
);
/// Event Store - 차지 공격 (DOT로 표현)
/// Event Store - 차지 마법 공격 (DOT로 표현)
static const eventStore = Skill(
id: 'event_store',
name: 'Event Store',
type: SkillType.attack,
tier: 4,
damageType: DamageType.magical,
mpCost: 200,
cooldownMs: 18000,
power: 0,
@@ -183,24 +196,26 @@ class SkillData {
baseDotTickMs: 3000,
);
/// Auto Scale - HP비례 공격
/// Auto Scale - HP비례 하이브리드 공격
static const autoScale = Skill(
id: 'auto_scale',
name: 'Auto Scale',
type: SkillType.attack,
tier: 4,
damageType: DamageType.hybrid,
mpCost: 230,
cooldownMs: 20000,
power: 30,
damageMultiplier: 2.5,
);
/// Disassemble - 방어감소+공격
/// Disassemble - 방어감소 + 물리 공격
static const disassemble = Skill(
id: 'disassemble',
name: 'Disassemble',
type: SkillType.attack,
tier: 3,
damageType: DamageType.physical,
mpCost: 150,
cooldownMs: 12000,
power: 22,
@@ -208,36 +223,39 @@ class SkillData {
targetDefReduction: 0.3,
);
/// Decompile - 약점 공격 (높은 크리)
/// Decompile - 약점 물리 공격 (높은 크리)
static const decompile = Skill(
id: 'decompile',
name: 'Decompile',
type: SkillType.attack,
tier: 2,
damageType: DamageType.physical,
mpCost: 130,
cooldownMs: 10000,
power: 20,
damageMultiplier: 2.2,
);
/// Canary Release - 테스트 공격
/// Canary Release - 테스트 물리 공격
static const canaryRelease = Skill(
id: 'canary_release',
name: 'Canary Release',
type: SkillType.attack,
tier: 1,
mpCost: 80,
damageType: DamageType.physical,
mpCost: 35, // T1 기본
cooldownMs: 6000,
power: 12,
damageMultiplier: 2.0,
);
/// A/B Test - 이중 공격
/// A/B Test - 이중 물리 공격
static const abTest = Skill(
id: 'ab_test',
name: 'A/B Test',
type: SkillType.attack,
tier: 2,
damageType: DamageType.physical,
mpCost: 180,
cooldownMs: 12000,
power: 15,
@@ -245,12 +263,13 @@ class SkillData {
hitCount: 2,
);
/// Pivot Network - 네트워크 공격
/// Pivot Network - 네트워크 마법 공격
static const pivotNetwork = Skill(
id: 'pivot_network',
name: 'Pivot Network',
type: SkillType.attack,
tier: 3,
damageType: DamageType.magical,
mpCost: 150,
cooldownMs: 10000,
power: 25,
@@ -258,24 +277,26 @@ class SkillData {
element: SkillElement.network,
);
/// Async Await - 딜레이 공격
/// Async Await - 딜레이 마법 공격
static const asyncAwait = Skill(
id: 'async_await',
name: 'Async Await',
type: SkillType.attack,
tier: 4,
damageType: DamageType.magical,
mpCost: 180,
cooldownMs: 14000,
power: 35,
damageMultiplier: 3.0,
);
/// Event Source - DOT 공격
/// Event Source - DOT 마법 공격
static const eventSource = Skill(
id: 'event_source',
name: 'Event Source',
type: SkillType.attack,
tier: 3,
damageType: DamageType.magical,
mpCost: 150,
cooldownMs: 12000,
power: 0,
@@ -286,12 +307,13 @@ class SkillData {
baseDotTickMs: 800,
);
/// CQRS Split - 분리 공격
/// CQRS Split - 분리 물리 공격
static const cqrsSplit = Skill(
id: 'cqrs_split',
name: 'CQRS Split',
type: SkillType.attack,
tier: 4,
damageType: DamageType.physical,
mpCost: 200,
cooldownMs: 15000,
power: 20,
@@ -464,190 +486,190 @@ class SkillData {
name: 'Debug Mode',
type: SkillType.buff,
tier: 3,
mpCost: 100,
mpCost: 140, // 100 → 140
cooldownMs: 20000,
power: 0,
buff: BuffEffect(
id: 'debug_mode_buff',
name: 'Debug Mode',
durationMs: 10000,
atkModifier: 0.25,
durationMs: 8000, // 10초 → 8초
atkModifier: 0.15, // 25% → 15%
),
);
/// Safe Mode - DEF +30%
/// Safe Mode - DEF 증가
static const safeMode = Skill(
id: 'safe_mode',
name: 'Safe Mode',
type: SkillType.buff,
tier: 3,
mpCost: 130,
mpCost: 160, // 130 → 160
cooldownMs: 25000,
power: 0,
buff: BuffEffect(
id: 'safe_mode_buff',
name: 'Safe Mode',
durationMs: 10000,
defModifier: 0.3,
durationMs: 8000, // 10초 → 8초
defModifier: 0.18, // 30% → 18%
),
);
/// Memory Optimization - 전스탯 +10%
/// Memory Optimization - 전스탯 증가
static const memoryOptimization = Skill(
id: 'memory_optimization',
name: 'Memory Optimization',
type: SkillType.buff,
tier: 3,
mpCost: 150,
mpCost: 180, // 150 → 180
cooldownMs: 30000,
power: 0,
buff: BuffEffect(
id: 'memory_optimization_buff',
name: 'Optimized',
durationMs: 15000,
atkModifier: 0.1,
defModifier: 0.1,
criRateModifier: 0.05,
evasionModifier: 0.05,
durationMs: 12000, // 15초 → 12초
atkModifier: 0.07, // 10% → 7%
defModifier: 0.07, // 10% → 7%
criRateModifier: 0.03, // 5% → 3%
evasionModifier: 0.03, // 5% → 3%
),
);
/// Breakpoint - 다음 공격 크리티컬
/// Breakpoint - 크리율 증가 (하이브리드 밸런스)
static const breakpoint = Skill(
id: 'breakpoint',
name: 'Breakpoint',
type: SkillType.buff,
tier: 2,
mpCost: 80,
mpCost: 120, // 70 → 120
cooldownMs: 12000,
power: 0,
buff: BuffEffect(
id: 'breakpoint_buff',
name: 'Breakpoint',
durationMs: 5000,
criRateModifier: 0.5,
criRateModifier: 0.20, // 35% → 20%
),
);
/// Watch Variable - 회피율 +20%
/// Watch Variable - 회피율 증가
static const watchVariable = Skill(
id: 'watch_variable',
name: 'Watch Variable',
type: SkillType.buff,
tier: 2,
mpCost: 90,
mpCost: 120, // 90 → 120
cooldownMs: 15000,
power: 0,
buff: BuffEffect(
id: 'watch_variable_buff',
name: 'Watching',
durationMs: 8000,
evasionModifier: 0.2,
durationMs: 6000, // 8초 → 6초
evasionModifier: 0.12, // 20% → 12%
),
);
/// Step Into - 공격속도 +30% (ATK 버프로 표현)
/// Step Into - 공격속도 증가 (ATK 버프로 표현)
static const stepInto = Skill(
id: 'step_into',
name: 'Step Into',
type: SkillType.buff,
tier: 1,
mpCost: 80,
mpCost: 100, // 80 → 100
cooldownMs: 12000,
power: 0,
buff: BuffEffect(
id: 'step_into_buff',
name: 'Step Into',
durationMs: 6000,
atkModifier: 0.2,
atkModifier: 0.12, // 20% → 12%
),
);
/// Profile Run - 크리율 +30%
/// Profile Run - 크리율 증가
static const profileRun = Skill(
id: 'profile_run',
name: 'Profile Run',
type: SkillType.buff,
tier: 3,
mpCost: 100,
mpCost: 150, // 100 → 150
cooldownMs: 18000,
power: 0,
buff: BuffEffect(
id: 'profile_run_buff',
name: 'Profiling',
durationMs: 8000,
criRateModifier: 0.3,
durationMs: 6000, // 8초 → 6초
criRateModifier: 0.18, // 30% → 18%
),
);
/// Benchmark - 데미지 +40%
/// Benchmark - 데미지 증가
static const benchmark = Skill(
id: 'benchmark',
name: 'Benchmark',
type: SkillType.buff,
tier: 4,
mpCost: 130,
mpCost: 180, // 130 → 180
cooldownMs: 20000,
power: 0,
buff: BuffEffect(
id: 'benchmark_buff',
name: 'Benchmarking',
durationMs: 5000,
atkModifier: 0.4,
atkModifier: 0.25, // 40% → 25%
),
);
/// Elevate Privilege - 전스탯 +20%
/// Elevate Privilege - 전스탯 증가 (하이브리드 밸런스)
static const elevatePrivilege = Skill(
id: 'elevate_privilege',
name: 'Elevate Privilege',
type: SkillType.buff,
tier: 5,
mpCost: 200,
mpCost: 280, // 220 → 280
cooldownMs: 35000,
power: 0,
buff: BuffEffect(
id: 'elevate_privilege_buff',
name: 'Elevated',
durationMs: 8000,
atkModifier: 0.2,
defModifier: 0.2,
criRateModifier: 0.1,
evasionModifier: 0.1,
durationMs: 6000, // 8초 → 6초
atkModifier: 0.08, // 12% → 8%
defModifier: 0.08, // 12% → 8%
criRateModifier: 0.04, // 6% → 4%
evasionModifier: 0.04, // 6% → 4%
),
);
/// Scale Up - ATK +50%
/// Scale Up - ATK 증가 (하이브리드 밸런스)
static const scaleUp = Skill(
id: 'scale_up',
name: 'Scale Up',
type: SkillType.buff,
tier: 5,
mpCost: 180,
mpCost: 280, // 220 → 280
cooldownMs: 30000,
power: 0,
buff: BuffEffect(
id: 'scale_up_buff',
name: 'Scaled Up',
durationMs: 6000,
atkModifier: 0.5,
durationMs: 5000, // 6초 → 5초
atkModifier: 0.20, // 30% → 20%
),
);
/// Failover - 치명타 방지 (DEF 증가로 표현)
/// Failover - DEF 증가 (하이브리드 밸런스)
static const failover = Skill(
id: 'failover',
name: 'Failover',
type: SkillType.buff,
tier: 5,
mpCost: 150,
mpCost: 350, // 300 → 350
cooldownMs: 45000,
power: 0,
buff: BuffEffect(
id: 'failover_buff',
name: 'Failover Ready',
durationMs: 30000,
defModifier: 0.4,
durationMs: 15000, // 20초 → 15초
defModifier: 0.18, // 25% → 18%
),
);
@@ -657,14 +679,14 @@ class SkillData {
name: 'Containerize',
type: SkillType.buff,
tier: 3,
mpCost: 130,
mpCost: 170, // 130 → 170
cooldownMs: 20000,
power: 0,
buff: BuffEffect(
id: 'containerize_buff',
name: 'Containerized',
durationMs: 12000,
defModifier: 0.35,
durationMs: 10000, // 12초 → 10초
defModifier: 0.20, // 35% → 20%
),
);
@@ -674,16 +696,16 @@ class SkillData {
name: 'Orchestrate',
type: SkillType.buff,
tier: 4,
mpCost: 230,
mpCost: 260, // 230 → 260
cooldownMs: 40000,
power: 0,
buff: BuffEffect(
id: 'orchestrate_buff',
name: 'Orchestrated',
durationMs: 10000,
atkModifier: 0.15,
defModifier: 0.15,
criRateModifier: 0.1,
durationMs: 8000, // 10초 → 8초
atkModifier: 0.10, // 15% → 10%
defModifier: 0.10, // 15% → 10%
criRateModifier: 0.06, // 10% → 6%
),
);
@@ -693,14 +715,14 @@ class SkillData {
name: 'Promise Resolve',
type: SkillType.buff,
tier: 2,
mpCost: 100,
mpCost: 130, // 100 → 130
cooldownMs: 15000,
power: 0,
buff: BuffEffect(
id: 'promise_resolve_buff',
name: 'Resolved',
durationMs: 8000,
atkModifier: 0.25,
durationMs: 6000, // 8초 → 6초
atkModifier: 0.15, // 25% → 15%
),
);
@@ -710,32 +732,32 @@ class SkillData {
name: 'Feature Toggle',
type: SkillType.buff,
tier: 1,
mpCost: 80,
mpCost: 110, // 80 → 110
cooldownMs: 10000,
power: 0,
buff: BuffEffect(
id: 'feature_toggle_buff',
name: 'Toggled',
durationMs: 12000,
atkModifier: 0.15,
defModifier: 0.15,
durationMs: 10000, // 12초 → 10초
atkModifier: 0.10, // 15% → 10%
defModifier: 0.10, // 15% → 10%
),
);
/// Dark Launch - 은신 (회피+100% → 50%로 조정)
/// Dark Launch - 은신 (하이브리드 밸런스)
static const darkLaunch = Skill(
id: 'dark_launch',
name: 'Dark Launch',
type: SkillType.buff,
tier: 4,
mpCost: 180,
mpCost: 180, // 150 → 180
cooldownMs: 30000,
power: 0,
buff: BuffEffect(
id: 'dark_launch_buff',
name: 'Hidden',
durationMs: 3000,
evasionModifier: 0.5,
evasionModifier: 0.20, // 30% → 20%
),
);
@@ -745,14 +767,14 @@ class SkillData {
name: 'Static Analysis',
type: SkillType.buff,
tier: 1,
mpCost: 80,
mpCost: 100, // 80 → 100
cooldownMs: 12000,
power: 0,
buff: BuffEffect(
id: 'static_analysis_buff',
name: 'Analyzed',
durationMs: 10000,
criRateModifier: 0.15,
durationMs: 8000, // 10초 → 8초
criRateModifier: 0.10, // 15% → 10%
),
);
@@ -762,15 +784,15 @@ class SkillData {
name: 'Dynamic Analysis',
type: SkillType.buff,
tier: 4,
mpCost: 130,
mpCost: 170, // 130 → 170
cooldownMs: 18000,
power: 0,
buff: BuffEffect(
id: 'dynamic_analysis_buff',
name: 'Dynamic',
durationMs: 8000,
criRateModifier: 0.15,
evasionModifier: 0.15,
durationMs: 6000, // 8초 → 6초
criRateModifier: 0.10, // 15% → 10%
evasionModifier: 0.10, // 15% → 10%
),
);
@@ -780,14 +802,14 @@ class SkillData {
name: 'Reverse Engineer',
type: SkillType.buff,
tier: 4,
mpCost: 150,
mpCost: 190, // 150 → 190
cooldownMs: 25000,
power: 0,
buff: BuffEffect(
id: 'reverse_engineer_buff',
name: 'Reversed',
durationMs: 10000,
atkModifier: 0.3,
durationMs: 8000, // 10초 → 8초
atkModifier: 0.18, // 30% → 18%
),
);
@@ -797,14 +819,14 @@ class SkillData {
name: 'Cover Tracks',
type: SkillType.buff,
tier: 2,
mpCost: 100,
mpCost: 130, // 100 → 130
cooldownMs: 15000,
power: 0,
buff: BuffEffect(
id: 'cover_tracks_buff',
name: 'Covered',
durationMs: 5000,
evasionModifier: 0.25,
evasionModifier: 0.15, // 25% → 15%
),
);
@@ -814,14 +836,14 @@ class SkillData {
name: 'Deploy',
type: SkillType.buff,
tier: 4,
mpCost: 180,
mpCost: 220, // 180 → 220
cooldownMs: 30000,
power: 0,
buff: BuffEffect(
id: 'deploy_buff',
name: 'Deployed',
durationMs: 12000,
atkModifier: 0.35,
durationMs: 10000, // 12초 → 10초
atkModifier: 0.22, // 35% → 22%
),
);
@@ -831,14 +853,14 @@ class SkillData {
name: 'Retry Logic',
type: SkillType.buff,
tier: 2,
mpCost: 100,
mpCost: 130, // 100 → 130
cooldownMs: 15000,
power: 0,
buff: BuffEffect(
id: 'retry_logic_buff',
name: 'Retrying',
durationMs: 10000,
criRateModifier: 0.2,
durationMs: 8000, // 10초 → 8초
criRateModifier: 0.12, // 20% → 12%
),
);
@@ -848,15 +870,15 @@ class SkillData {
name: 'State Machine',
type: SkillType.buff,
tier: 2,
mpCost: 150,
mpCost: 170, // 150 → 170
cooldownMs: 25000,
power: 0,
buff: BuffEffect(
id: 'state_machine_buff',
name: 'State Active',
durationMs: 15000,
atkModifier: 0.1,
defModifier: 0.1,
durationMs: 12000, // 15초 → 12초
atkModifier: 0.08, // 10% → 8%
defModifier: 0.08, // 10% → 8%
),
);

View File

@@ -227,8 +227,8 @@
"total": "Total",
"@total": { "description": "Total label for stats" },
"unroll": "Unroll",
"@unroll": { "description": "Unroll button" },
"unroll": "Undo",
"@unroll": { "description": "Undo button for stat reroll" },
"roll": "Roll",
"@roll": { "description": "Roll button" },
@@ -310,5 +310,401 @@
"@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" },
"arenaTitle": "LOCAL ARENA",
"@arenaTitle": { "description": "Arena main screen title" },
"arenaSelectFighter": "SELECT YOUR FIGHTER",
"@arenaSelectFighter": { "description": "Arena character selection subtitle" },
"arenaEmptyTitle": "Not enough heroes",
"@arenaEmptyTitle": { "description": "Arena empty state title" },
"arenaEmptyHint": "Clear the game with 2+ characters",
"@arenaEmptyHint": { "description": "Arena empty state hint" },
"arenaSetupTitle": "ARENA SETUP",
"@arenaSetupTitle": { "description": "Arena setup screen title" },
"arenaStartBattle": "START BATTLE",
"@arenaStartBattle": { "description": "Start battle button" },
"arenaBattleTitle": "ARENA BATTLE",
"@arenaBattleTitle": { "description": "Arena battle screen title" },
"arenaMyEquipment": "MY EQUIPMENT",
"@arenaMyEquipment": { "description": "My equipment header" },
"arenaEnemyEquipment": "ENEMY EQUIPMENT",
"@arenaEnemyEquipment": { "description": "Enemy equipment header" },
"arenaSelected": "SELECTED",
"@arenaSelected": { "description": "Selected slot label" },
"arenaRecommended": "BEST",
"@arenaRecommended": { "description": "Recommended slot label" },
"arenaWeaponLocked": "LOCKED",
"@arenaWeaponLocked": { "description": "Weapon slot locked label" },
"arenaVictory": "VICTORY!",
"@arenaVictory": { "description": "Arena victory title" },
"arenaDefeat": "DEFEAT...",
"@arenaDefeat": { "description": "Arena defeat title" },
"arenaEquipmentExchange": "EQUIPMENT EXCHANGE",
"@arenaEquipmentExchange": { "description": "Equipment exchange section title" },
"arenaTurns": "TURNS",
"@arenaTurns": { "description": "Turns label" },
"arenaWinner": "WINNER",
"@arenaWinner": { "description": "Winner label" },
"arenaLoser": "LOSER",
"@arenaLoser": { "description": "Loser label" },
"arenaDefeatedIn": "{winner} defeated {loser} in {turns} TURNS",
"@arenaDefeatedIn": {
"description": "Battle summary text",
"placeholders": {
"winner": { "type": "String" },
"loser": { "type": "String" },
"turns": { "type": "int" }
}
},
"arenaScoreGain": "You will GAIN +{score}",
"@arenaScoreGain": {
"description": "Score gain prediction",
"placeholders": {
"score": { "type": "int" }
}
},
"arenaScoreLose": "You will LOSE {score}",
"@arenaScoreLose": {
"description": "Score loss prediction",
"placeholders": {
"score": { "type": "int" }
}
},
"arenaEvenTrade": "Even trade",
"@arenaEvenTrade": { "description": "Even trade label" },
"arenaScore": "SCORE",
"@arenaScore": { "description": "Score label" },
"statsStatistics": "Statistics",
"@statsStatistics": { "description": "Statistics dialog title" },
"statsSession": "Session",
"@statsSession": { "description": "Session tab label" },
"statsAccumulated": "Total",
"@statsAccumulated": { "description": "Accumulated tab label" },
"statsCombat": "Combat",
"@statsCombat": { "description": "Combat section title" },
"statsPlayTime": "Play Time",
"@statsPlayTime": { "description": "Play time label" },
"statsMonstersKilled": "Monsters Killed",
"@statsMonstersKilled": { "description": "Monsters killed label" },
"statsBossesDefeated": "Bosses Defeated",
"@statsBossesDefeated": { "description": "Bosses defeated label" },
"statsDeaths": "Deaths",
"@statsDeaths": { "description": "Deaths label" },
"statsDamage": "Damage",
"@statsDamage": { "description": "Damage section title" },
"statsDamageDealt": "Damage Dealt",
"@statsDamageDealt": { "description": "Damage dealt label" },
"statsDamageTaken": "Damage Taken",
"@statsDamageTaken": { "description": "Damage taken label" },
"statsAverageDps": "Average DPS",
"@statsAverageDps": { "description": "Average DPS label" },
"statsSkills": "Skills",
"@statsSkills": { "description": "Skills section title" },
"statsSkillsUsed": "Skills Used",
"@statsSkillsUsed": { "description": "Skills used label" },
"statsCriticalHits": "Critical Hits",
"@statsCriticalHits": { "description": "Critical hits label" },
"statsMaxCriticalStreak": "Max Critical Streak",
"@statsMaxCriticalStreak": { "description": "Max critical streak label" },
"statsCriticalRate": "Critical Rate",
"@statsCriticalRate": { "description": "Critical rate label" },
"statsEconomy": "Economy",
"@statsEconomy": { "description": "Economy section title" },
"statsGoldEarned": "Gold Earned",
"@statsGoldEarned": { "description": "Gold earned label" },
"statsGoldSpent": "Gold Spent",
"@statsGoldSpent": { "description": "Gold spent label" },
"statsItemsSold": "Items Sold",
"@statsItemsSold": { "description": "Items sold label" },
"statsPotionsUsed": "Potions Used",
"@statsPotionsUsed": { "description": "Potions used label" },
"statsProgress": "Progress",
"@statsProgress": { "description": "Progress section title" },
"statsLevelUps": "Level Ups",
"@statsLevelUps": { "description": "Level ups label" },
"statsQuestsCompleted": "Quests Completed",
"@statsQuestsCompleted": { "description": "Quests completed label" },
"statsRecords": "Records",
"@statsRecords": { "description": "Records section title" },
"statsHighestLevel": "Highest Level",
"@statsHighestLevel": { "description": "Highest level label" },
"statsHighestGoldHeld": "Highest Gold Held",
"@statsHighestGoldHeld": { "description": "Highest gold held label" },
"statsBestCriticalStreak": "Best Critical Streak",
"@statsBestCriticalStreak": { "description": "Best critical streak label" },
"statsTotalPlay": "Total Play",
"@statsTotalPlay": { "description": "Total play section title" },
"statsTotalPlayTime": "Total Play Time",
"@statsTotalPlayTime": { "description": "Total play time label" },
"statsGamesStarted": "Games Started",
"@statsGamesStarted": { "description": "Games started label" },
"statsGamesCompleted": "Games Completed",
"@statsGamesCompleted": { "description": "Games completed label" },
"statsCompletionRate": "Completion Rate",
"@statsCompletionRate": { "description": "Completion rate label" },
"statsTotalCombat": "Total Combat",
"@statsTotalCombat": { "description": "Total combat section title" },
"statsTotalDeaths": "Total Deaths",
"@statsTotalDeaths": { "description": "Total deaths label" },
"statsTotalLevelUps": "Total Level Ups",
"@statsTotalLevelUps": { "description": "Total level ups label" },
"statsTotalDamage": "Total Damage",
"@statsTotalDamage": { "description": "Total damage section title" },
"statsTotalSkills": "Total Skills",
"@statsTotalSkills": { "description": "Total skills section title" },
"statsTotalEconomy": "Total Economy",
"@statsTotalEconomy": { "description": "Total economy section title" },
"notifyLevelUpLabel": "LEVEL UP",
"@notifyLevelUpLabel": { "description": "Level up notification type label" },
"notifyQuestDoneLabel": "QUEST DONE",
"@notifyQuestDoneLabel": { "description": "Quest done notification type label" },
"notifyActClearLabel": "ACT CLEAR",
"@notifyActClearLabel": { "description": "Act clear notification type label" },
"notifyNewSpellLabel": "NEW SPELL",
"@notifyNewSpellLabel": { "description": "New spell notification type label" },
"notifyNewItemLabel": "NEW ITEM",
"@notifyNewItemLabel": { "description": "New item notification type label" },
"notifyBossSlainLabel": "BOSS SLAIN",
"@notifyBossSlainLabel": { "description": "Boss slain notification type label" },
"notifySavedLabel": "SAVED",
"@notifySavedLabel": { "description": "Game saved notification type label" },
"notifyInfoLabel": "INFO",
"@notifyInfoLabel": { "description": "Info notification type label" },
"notifyWarningLabel": "WARNING",
"@notifyWarningLabel": { "description": "Warning notification type label" }
}

View File

@@ -1,80 +1,80 @@
{
"@@locale": "ja",
"appTitle": "ASCII NEVER DIE",
"tagNoNetwork": "No network",
"tagIdleRpg": "Idle RPG loop",
"tagLocalSaves": "Local saves",
"newCharacter": "New character",
"loadSave": "Load save",
"loadGame": "Load Game",
"viewBuildPlan": "View build plan",
"buildRoadmap": "Build roadmap",
"techStack": "Tech stack",
"cancel": "Cancel",
"exitGame": "Exit Game",
"saveProgressQuestion": "Save your progress before leaving?",
"exitWithoutSaving": "Exit without saving",
"saveAndExit": "Save and Exit",
"progressQuestTitle": "ASCII NEVER DIE - {name}",
"levelUp": "Level Up",
"completeQuest": "Complete Quest",
"completePlot": "Complete Plot",
"characterSheet": "Character Sheet",
"traits": "Traits",
"stats": "Stats",
"experience": "Experience",
"xpNeededForNextLevel": "XP needed for next level",
"appTitle": "アスキー ネバー ダイ",
"tagNoNetwork": "オフライン",
"tagIdleRpg": "放置型RPG",
"tagLocalSaves": "ローカル保存",
"newCharacter": "新規キャラクター",
"loadSave": "ロード",
"loadGame": "ゲームをロード",
"viewBuildPlan": "ビルド計画を見る",
"buildRoadmap": "ビルドロードマップ",
"techStack": "技術スタック",
"cancel": "キャンセル",
"exitGame": "ゲーム終了",
"saveProgressQuestion": "終了する前にセーブしますか?",
"exitWithoutSaving": "セーブせずに終了",
"saveAndExit": "セーブして終了",
"progressQuestTitle": "アスキー ネバー ダイ - {name}",
"levelUp": "レベルアップ",
"completeQuest": "クエスト完了",
"completePlot": "プロット完了",
"characterSheet": "キャラクターシート",
"traits": "特性",
"stats": "能力値",
"experience": "経験値",
"xpNeededForNextLevel": "次のレベルまでの必要XP",
"spellBook": "スキル",
"noSpellsYet": "習得したスキルがありません",
"equipment": "Equipment",
"inventory": "Inventory",
"encumbrance": "Encumbrance",
"equipment": "装備",
"inventory": "インベントリ",
"encumbrance": "積載量",
"combatLog": "戦闘ログ",
"plotDevelopment": "Plot Development",
"quests": "Quests",
"traitName": "Name",
"traitRace": "Race",
"traitClass": "Class",
"traitLevel": "Level",
"plotDevelopment": "ストーリー進行",
"quests": "クエスト",
"traitName": "名前",
"traitRace": "種族",
"traitClass": "職業",
"traitLevel": "レベル",
"statStr": "STR",
"statCon": "CON",
"statDex": "DEX",
"statInt": "INT",
"statWis": "WIS",
"statCha": "CHA",
"statHpMax": "HP Max",
"statMpMax": "MP Max",
"equipWeapon": "Weapon",
"equipShield": "Shield",
"equipHelm": "Helm",
"equipHauberk": "Hauberk",
"equipBrassairts": "Brassairts",
"equipVambraces": "Vambraces",
"equipGauntlets": "Gauntlets",
"equipGambeson": "Gambeson",
"equipCuisses": "Cuisses",
"equipGreaves": "Greaves",
"equipSollerets": "Sollerets",
"statHpMax": "HP最大",
"statMpMax": "MP最大",
"equipWeapon": "武器",
"equipShield": "",
"equipHelm": "",
"equipHauberk": "",
"equipBrassairts": "肩当て",
"equipVambraces": "腕甲",
"equipGauntlets": "篭手",
"equipGambeson": "防護服",
"equipCuisses": "腿当て",
"equipGreaves": "脛当て",
"equipSollerets": "鉄靴",
"gold": "コイン",
"goldAmount": "コイン: {amount}",
"prologue": "Prologue",
"actNumber": "Act {number}",
"noActiveQuests": "No active quests",
"questNumber": "Quest #{number}",
"prologue": "プロローグ",
"actNumber": "{number}",
"noActiveQuests": "進行中のクエストなし",
"questNumber": "クエスト #{number}",
"welcomeMessage": "ASCII NEVER DIEへようこそ",
"noSavedGames": "No saved games found.",
"loadError": "Failed to load save file: {error}",
"name": "Name",
"generateName": "Generate Name",
"total": "Total",
"unroll": "Unroll",
"roll": "Roll",
"race": "Race",
"classTitle": "Class",
"percentComplete": "{percent}% complete",
"newCharacterTitle": "ASCII NEVER DIE - New Character",
"soldButton": "Sold!",
"noSavedGames": "セーブデータがありません。",
"loadError": "セーブファイルの読み込みに失敗しました: {error}",
"name": "名前",
"generateName": "名前を生成",
"total": "合計",
"unroll": "元に戻す",
"roll": "ロール",
"race": "種族",
"classTitle": "職業",
"percentComplete": "{percent}% 完了",
"newCharacterTitle": "アスキー ネバー ダイ - 新規キャラクター",
"soldButton": "決定!",
"endingCongratulations": "★ おめでとうございます ★",
"endingGameComplete": "ゲームをクリアしました!",
@@ -93,5 +93,132 @@
"endingHallOfFameButton": "殿堂入り",
"endingSkip": "スキップ",
"endingTapToSkip": "タップでスキップ",
"endingHoldToSpeedUp": "長押しで高速スクロール"
"endingHoldToSpeedUp": "長押しで高速スクロール",
"menuTitle": "メニュー",
"optionsTitle": "オプション",
"soundTitle": "サウンド",
"controlSection": "操作",
"infoSection": "情報",
"settingsSection": "設定",
"saveExitSection": "セーブ / 終了",
"ok": "OK",
"rechargeButton": "チャージ",
"createButton": "作成",
"previewTitle": "プレビュー",
"nameTitle": "名前",
"statsTitle": "能力値",
"raceTitle": "種族",
"classSection": "職業",
"bgmLabel": "BGM",
"sfxLabel": "効果音",
"hpLabel": "HP",
"mpLabel": "MP",
"expLabel": "EXP",
"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殿堂に登録します。",
"arenaTitle": "ローカルアリーナ",
"arenaSelectFighter": "ファイターを選択",
"arenaEmptyTitle": "ヒーローが不足しています",
"arenaEmptyHint": "2人以上のキャラでクリアしてください",
"arenaSetupTitle": "アリーナ設定",
"arenaStartBattle": "バトル開始",
"arenaBattleTitle": "アリーナバトル",
"arenaMyEquipment": "自分の装備",
"arenaEnemyEquipment": "敵の装備",
"arenaSelected": "選択済み",
"arenaRecommended": "おすすめ",
"arenaWeaponLocked": "ロック",
"arenaVictory": "勝利!",
"arenaDefeat": "敗北...",
"arenaEquipmentExchange": "装備交換",
"arenaTurns": "ターン",
"arenaWinner": "勝者",
"arenaLoser": "敗者",
"arenaDefeatedIn": "{winner}が{loser}を{turns}ターンで撃破",
"arenaScoreGain": "+{score}獲得予定",
"arenaScoreLose": "{score}損失予定",
"arenaEvenTrade": "等価交換",
"arenaScore": "スコア",
"statsStatistics": "統計",
"statsSession": "セッション",
"statsAccumulated": "累積",
"statsCombat": "戦闘",
"statsPlayTime": "プレイ時間",
"statsMonstersKilled": "倒したモンスター",
"statsBossesDefeated": "ボス討伐",
"statsDeaths": "死亡回数",
"statsDamage": "ダメージ",
"statsDamageDealt": "与えたダメージ",
"statsDamageTaken": "受けたダメージ",
"statsAverageDps": "平均DPS",
"statsSkills": "スキル",
"statsSkillsUsed": "スキル使用",
"statsCriticalHits": "クリティカルヒット",
"statsMaxCriticalStreak": "最大連続クリティカル",
"statsCriticalRate": "クリティカル率",
"statsEconomy": "経済",
"statsGoldEarned": "獲得ゴールド",
"statsGoldSpent": "消費ゴールド",
"statsItemsSold": "売却アイテム",
"statsPotionsUsed": "ポーション使用",
"statsProgress": "進行",
"statsLevelUps": "レベルアップ",
"statsQuestsCompleted": "完了したクエスト",
"statsRecords": "記録",
"statsHighestLevel": "最高レベル",
"statsHighestGoldHeld": "最大所持ゴールド",
"statsBestCriticalStreak": "最高連続クリティカル",
"statsTotalPlay": "総プレイ",
"statsTotalPlayTime": "総プレイ時間",
"statsGamesStarted": "開始したゲーム",
"statsGamesCompleted": "クリアしたゲーム",
"statsCompletionRate": "クリア率",
"statsTotalCombat": "総戦闘",
"statsTotalDeaths": "総死亡",
"statsTotalLevelUps": "総レベルアップ",
"statsTotalDamage": "総ダメージ",
"statsTotalSkills": "総スキル",
"statsTotalEconomy": "総経済",
"notifyLevelUpLabel": "レベルアップ",
"notifyQuestDoneLabel": "クエスト完了",
"notifyActClearLabel": "幕完了",
"notifyNewSpellLabel": "新しいスキル",
"notifyNewItemLabel": "新しいアイテム",
"notifyBossSlainLabel": "ボス撃破",
"notifySavedLabel": "セーブ済み",
"notifyInfoLabel": "情報",
"notifyWarningLabel": "警告"
}

View File

@@ -68,7 +68,7 @@
"name": "이름",
"generateName": "이름 생성",
"total": "합계",
"unroll": "펼치기",
"unroll": "되돌리기",
"roll": "굴리기",
"race": "종족",
"classTitle": "직업",
@@ -93,5 +93,132 @@
"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명예의 전당에 등록합니다.",
"arenaTitle": "로컬 아레나",
"arenaSelectFighter": "전사를 선택하세요",
"arenaEmptyTitle": "영웅이 부족합니다",
"arenaEmptyHint": "2명 이상 캐릭터로 클리어하세요",
"arenaSetupTitle": "아레나 설정",
"arenaStartBattle": "전투 시작",
"arenaBattleTitle": "아레나 전투",
"arenaMyEquipment": "내 장비",
"arenaEnemyEquipment": "상대 장비",
"arenaSelected": "선택됨",
"arenaRecommended": "추천",
"arenaWeaponLocked": "잠김",
"arenaVictory": "승리!",
"arenaDefeat": "패배...",
"arenaEquipmentExchange": "장비 교환",
"arenaTurns": "턴",
"arenaWinner": "승자",
"arenaLoser": "패자",
"arenaDefeatedIn": "{winner}이(가) {loser}을(를) {turns}턴 만에 격파",
"arenaScoreGain": "+{score} 획득 예정",
"arenaScoreLose": "{score} 손실 예정",
"arenaEvenTrade": "등가 교환",
"arenaScore": "점수",
"statsStatistics": "통계",
"statsSession": "세션",
"statsAccumulated": "누적",
"statsCombat": "전투",
"statsPlayTime": "플레이 시간",
"statsMonstersKilled": "처치한 몬스터",
"statsBossesDefeated": "보스 처치",
"statsDeaths": "사망 횟수",
"statsDamage": "데미지",
"statsDamageDealt": "입힌 데미지",
"statsDamageTaken": "받은 데미지",
"statsAverageDps": "평균 DPS",
"statsSkills": "스킬",
"statsSkillsUsed": "스킬 사용",
"statsCriticalHits": "크리티컬 히트",
"statsMaxCriticalStreak": "최대 연속 크리티컬",
"statsCriticalRate": "크리티컬 비율",
"statsEconomy": "경제",
"statsGoldEarned": "획득 골드",
"statsGoldSpent": "소비 골드",
"statsItemsSold": "판매 아이템",
"statsPotionsUsed": "물약 사용",
"statsProgress": "진행",
"statsLevelUps": "레벨업",
"statsQuestsCompleted": "완료한 퀘스트",
"statsRecords": "기록",
"statsHighestLevel": "최고 레벨",
"statsHighestGoldHeld": "최대 보유 골드",
"statsBestCriticalStreak": "최고 연속 크리티컬",
"statsTotalPlay": "총 플레이",
"statsTotalPlayTime": "총 플레이 시간",
"statsGamesStarted": "시작한 게임",
"statsGamesCompleted": "클리어한 게임",
"statsCompletionRate": "클리어율",
"statsTotalCombat": "총 전투",
"statsTotalDeaths": "총 사망",
"statsTotalLevelUps": "총 레벨업",
"statsTotalDamage": "총 데미지",
"statsTotalSkills": "총 스킬",
"statsTotalEconomy": "총 경제",
"notifyLevelUpLabel": "레벨 업",
"notifyQuestDoneLabel": "퀘스트 완료",
"notifyActClearLabel": "막 완료",
"notifyNewSpellLabel": "새 주문",
"notifyNewItemLabel": "새 아이템",
"notifyBossSlainLabel": "보스 처치",
"notifySavedLabel": "저장됨",
"notifyInfoLabel": "정보",
"notifyWarningLabel": "경고"
}

View File

@@ -8,7 +8,6 @@ import 'package:intl/intl.dart' as intl;
import 'app_localizations_en.dart';
import 'app_localizations_ja.dart';
import 'app_localizations_ko.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
@@ -98,7 +97,6 @@ abstract class L10n {
Locale('en'),
Locale('ja'),
Locale('ko'),
Locale('zh'),
];
/// Application title
@@ -503,10 +501,10 @@ abstract class L10n {
/// **'Total'**
String get total;
/// Unroll button
/// Undo button for stat reroll
///
/// In en, this message translates to:
/// **'Unroll'**
/// **'Undo'**
String get unroll;
/// Roll button
@@ -652,6 +650,744 @@ 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;
/// Arena main screen title
///
/// In en, this message translates to:
/// **'LOCAL ARENA'**
String get arenaTitle;
/// Arena character selection subtitle
///
/// In en, this message translates to:
/// **'SELECT YOUR FIGHTER'**
String get arenaSelectFighter;
/// Arena empty state title
///
/// In en, this message translates to:
/// **'Not enough heroes'**
String get arenaEmptyTitle;
/// Arena empty state hint
///
/// In en, this message translates to:
/// **'Clear the game with 2+ characters'**
String get arenaEmptyHint;
/// Arena setup screen title
///
/// In en, this message translates to:
/// **'ARENA SETUP'**
String get arenaSetupTitle;
/// Start battle button
///
/// In en, this message translates to:
/// **'START BATTLE'**
String get arenaStartBattle;
/// Arena battle screen title
///
/// In en, this message translates to:
/// **'ARENA BATTLE'**
String get arenaBattleTitle;
/// My equipment header
///
/// In en, this message translates to:
/// **'MY EQUIPMENT'**
String get arenaMyEquipment;
/// Enemy equipment header
///
/// In en, this message translates to:
/// **'ENEMY EQUIPMENT'**
String get arenaEnemyEquipment;
/// Selected slot label
///
/// In en, this message translates to:
/// **'SELECTED'**
String get arenaSelected;
/// Recommended slot label
///
/// In en, this message translates to:
/// **'BEST'**
String get arenaRecommended;
/// Weapon slot locked label
///
/// In en, this message translates to:
/// **'LOCKED'**
String get arenaWeaponLocked;
/// Arena victory title
///
/// In en, this message translates to:
/// **'VICTORY!'**
String get arenaVictory;
/// Arena defeat title
///
/// In en, this message translates to:
/// **'DEFEAT...'**
String get arenaDefeat;
/// Equipment exchange section title
///
/// In en, this message translates to:
/// **'EQUIPMENT EXCHANGE'**
String get arenaEquipmentExchange;
/// Turns label
///
/// In en, this message translates to:
/// **'TURNS'**
String get arenaTurns;
/// Winner label
///
/// In en, this message translates to:
/// **'WINNER'**
String get arenaWinner;
/// Loser label
///
/// In en, this message translates to:
/// **'LOSER'**
String get arenaLoser;
/// Battle summary text
///
/// In en, this message translates to:
/// **'{winner} defeated {loser} in {turns} TURNS'**
String arenaDefeatedIn(String winner, String loser, int turns);
/// Score gain prediction
///
/// In en, this message translates to:
/// **'You will GAIN +{score}'**
String arenaScoreGain(int score);
/// Score loss prediction
///
/// In en, this message translates to:
/// **'You will LOSE {score}'**
String arenaScoreLose(int score);
/// Even trade label
///
/// In en, this message translates to:
/// **'Even trade'**
String get arenaEvenTrade;
/// Score label
///
/// In en, this message translates to:
/// **'SCORE'**
String get arenaScore;
/// Statistics dialog title
///
/// In en, this message translates to:
/// **'Statistics'**
String get statsStatistics;
/// Session tab label
///
/// In en, this message translates to:
/// **'Session'**
String get statsSession;
/// Accumulated tab label
///
/// In en, this message translates to:
/// **'Total'**
String get statsAccumulated;
/// Combat section title
///
/// In en, this message translates to:
/// **'Combat'**
String get statsCombat;
/// Play time label
///
/// In en, this message translates to:
/// **'Play Time'**
String get statsPlayTime;
/// Monsters killed label
///
/// In en, this message translates to:
/// **'Monsters Killed'**
String get statsMonstersKilled;
/// Bosses defeated label
///
/// In en, this message translates to:
/// **'Bosses Defeated'**
String get statsBossesDefeated;
/// Deaths label
///
/// In en, this message translates to:
/// **'Deaths'**
String get statsDeaths;
/// Damage section title
///
/// In en, this message translates to:
/// **'Damage'**
String get statsDamage;
/// Damage dealt label
///
/// In en, this message translates to:
/// **'Damage Dealt'**
String get statsDamageDealt;
/// Damage taken label
///
/// In en, this message translates to:
/// **'Damage Taken'**
String get statsDamageTaken;
/// Average DPS label
///
/// In en, this message translates to:
/// **'Average DPS'**
String get statsAverageDps;
/// Skills section title
///
/// In en, this message translates to:
/// **'Skills'**
String get statsSkills;
/// Skills used label
///
/// In en, this message translates to:
/// **'Skills Used'**
String get statsSkillsUsed;
/// Critical hits label
///
/// In en, this message translates to:
/// **'Critical Hits'**
String get statsCriticalHits;
/// Max critical streak label
///
/// In en, this message translates to:
/// **'Max Critical Streak'**
String get statsMaxCriticalStreak;
/// Critical rate label
///
/// In en, this message translates to:
/// **'Critical Rate'**
String get statsCriticalRate;
/// Economy section title
///
/// In en, this message translates to:
/// **'Economy'**
String get statsEconomy;
/// Gold earned label
///
/// In en, this message translates to:
/// **'Gold Earned'**
String get statsGoldEarned;
/// Gold spent label
///
/// In en, this message translates to:
/// **'Gold Spent'**
String get statsGoldSpent;
/// Items sold label
///
/// In en, this message translates to:
/// **'Items Sold'**
String get statsItemsSold;
/// Potions used label
///
/// In en, this message translates to:
/// **'Potions Used'**
String get statsPotionsUsed;
/// Progress section title
///
/// In en, this message translates to:
/// **'Progress'**
String get statsProgress;
/// Level ups label
///
/// In en, this message translates to:
/// **'Level Ups'**
String get statsLevelUps;
/// Quests completed label
///
/// In en, this message translates to:
/// **'Quests Completed'**
String get statsQuestsCompleted;
/// Records section title
///
/// In en, this message translates to:
/// **'Records'**
String get statsRecords;
/// Highest level label
///
/// In en, this message translates to:
/// **'Highest Level'**
String get statsHighestLevel;
/// Highest gold held label
///
/// In en, this message translates to:
/// **'Highest Gold Held'**
String get statsHighestGoldHeld;
/// Best critical streak label
///
/// In en, this message translates to:
/// **'Best Critical Streak'**
String get statsBestCriticalStreak;
/// Total play section title
///
/// In en, this message translates to:
/// **'Total Play'**
String get statsTotalPlay;
/// Total play time label
///
/// In en, this message translates to:
/// **'Total Play Time'**
String get statsTotalPlayTime;
/// Games started label
///
/// In en, this message translates to:
/// **'Games Started'**
String get statsGamesStarted;
/// Games completed label
///
/// In en, this message translates to:
/// **'Games Completed'**
String get statsGamesCompleted;
/// Completion rate label
///
/// In en, this message translates to:
/// **'Completion Rate'**
String get statsCompletionRate;
/// Total combat section title
///
/// In en, this message translates to:
/// **'Total Combat'**
String get statsTotalCombat;
/// Total deaths label
///
/// In en, this message translates to:
/// **'Total Deaths'**
String get statsTotalDeaths;
/// Total level ups label
///
/// In en, this message translates to:
/// **'Total Level Ups'**
String get statsTotalLevelUps;
/// Total damage section title
///
/// In en, this message translates to:
/// **'Total Damage'**
String get statsTotalDamage;
/// Total skills section title
///
/// In en, this message translates to:
/// **'Total Skills'**
String get statsTotalSkills;
/// Total economy section title
///
/// In en, this message translates to:
/// **'Total Economy'**
String get statsTotalEconomy;
/// Level up notification type label
///
/// In en, this message translates to:
/// **'LEVEL UP'**
String get notifyLevelUpLabel;
/// Quest done notification type label
///
/// In en, this message translates to:
/// **'QUEST DONE'**
String get notifyQuestDoneLabel;
/// Act clear notification type label
///
/// In en, this message translates to:
/// **'ACT CLEAR'**
String get notifyActClearLabel;
/// New spell notification type label
///
/// In en, this message translates to:
/// **'NEW SPELL'**
String get notifyNewSpellLabel;
/// New item notification type label
///
/// In en, this message translates to:
/// **'NEW ITEM'**
String get notifyNewItemLabel;
/// Boss slain notification type label
///
/// In en, this message translates to:
/// **'BOSS SLAIN'**
String get notifyBossSlainLabel;
/// Game saved notification type label
///
/// In en, this message translates to:
/// **'SAVED'**
String get notifySavedLabel;
/// Info notification type label
///
/// In en, this message translates to:
/// **'INFO'**
String get notifyInfoLabel;
/// Warning notification type label
///
/// In en, this message translates to:
/// **'WARNING'**
String get notifyWarningLabel;
}
class _L10nDelegate extends LocalizationsDelegate<L10n> {
@@ -664,7 +1400,7 @@ class _L10nDelegate extends LocalizationsDelegate<L10n> {
@override
bool isSupported(Locale locale) =>
<String>['en', 'ja', 'ko', 'zh'].contains(locale.languageCode);
<String>['en', 'ja', 'ko'].contains(locale.languageCode);
@override
bool shouldReload(_L10nDelegate old) => false;
@@ -679,8 +1415,6 @@ L10n lookupL10n(Locale locale) {
return L10nJa();
case 'ko':
return L10nKo();
case 'zh':
return L10nZh();
}
throw FlutterError(

View File

@@ -220,7 +220,7 @@ class L10nEn extends L10n {
String get total => 'Total';
@override
String get unroll => 'Unroll';
String get unroll => 'Undo';
@override
String get roll => 'Roll';
@@ -297,4 +297,387 @@ 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.';
@override
String get arenaTitle => 'LOCAL ARENA';
@override
String get arenaSelectFighter => 'SELECT YOUR FIGHTER';
@override
String get arenaEmptyTitle => 'Not enough heroes';
@override
String get arenaEmptyHint => 'Clear the game with 2+ characters';
@override
String get arenaSetupTitle => 'ARENA SETUP';
@override
String get arenaStartBattle => 'START BATTLE';
@override
String get arenaBattleTitle => 'ARENA BATTLE';
@override
String get arenaMyEquipment => 'MY EQUIPMENT';
@override
String get arenaEnemyEquipment => 'ENEMY EQUIPMENT';
@override
String get arenaSelected => 'SELECTED';
@override
String get arenaRecommended => 'BEST';
@override
String get arenaWeaponLocked => 'LOCKED';
@override
String get arenaVictory => 'VICTORY!';
@override
String get arenaDefeat => 'DEFEAT...';
@override
String get arenaEquipmentExchange => 'EQUIPMENT EXCHANGE';
@override
String get arenaTurns => 'TURNS';
@override
String get arenaWinner => 'WINNER';
@override
String get arenaLoser => 'LOSER';
@override
String arenaDefeatedIn(String winner, String loser, int turns) {
return '$winner defeated $loser in $turns TURNS';
}
@override
String arenaScoreGain(int score) {
return 'You will GAIN +$score';
}
@override
String arenaScoreLose(int score) {
return 'You will LOSE $score';
}
@override
String get arenaEvenTrade => 'Even trade';
@override
String get arenaScore => 'SCORE';
@override
String get statsStatistics => 'Statistics';
@override
String get statsSession => 'Session';
@override
String get statsAccumulated => 'Total';
@override
String get statsCombat => 'Combat';
@override
String get statsPlayTime => 'Play Time';
@override
String get statsMonstersKilled => 'Monsters Killed';
@override
String get statsBossesDefeated => 'Bosses Defeated';
@override
String get statsDeaths => 'Deaths';
@override
String get statsDamage => 'Damage';
@override
String get statsDamageDealt => 'Damage Dealt';
@override
String get statsDamageTaken => 'Damage Taken';
@override
String get statsAverageDps => 'Average DPS';
@override
String get statsSkills => 'Skills';
@override
String get statsSkillsUsed => 'Skills Used';
@override
String get statsCriticalHits => 'Critical Hits';
@override
String get statsMaxCriticalStreak => 'Max Critical Streak';
@override
String get statsCriticalRate => 'Critical Rate';
@override
String get statsEconomy => 'Economy';
@override
String get statsGoldEarned => 'Gold Earned';
@override
String get statsGoldSpent => 'Gold Spent';
@override
String get statsItemsSold => 'Items Sold';
@override
String get statsPotionsUsed => 'Potions Used';
@override
String get statsProgress => 'Progress';
@override
String get statsLevelUps => 'Level Ups';
@override
String get statsQuestsCompleted => 'Quests Completed';
@override
String get statsRecords => 'Records';
@override
String get statsHighestLevel => 'Highest Level';
@override
String get statsHighestGoldHeld => 'Highest Gold Held';
@override
String get statsBestCriticalStreak => 'Best Critical Streak';
@override
String get statsTotalPlay => 'Total Play';
@override
String get statsTotalPlayTime => 'Total Play Time';
@override
String get statsGamesStarted => 'Games Started';
@override
String get statsGamesCompleted => 'Games Completed';
@override
String get statsCompletionRate => 'Completion Rate';
@override
String get statsTotalCombat => 'Total Combat';
@override
String get statsTotalDeaths => 'Total Deaths';
@override
String get statsTotalLevelUps => 'Total Level Ups';
@override
String get statsTotalDamage => 'Total Damage';
@override
String get statsTotalSkills => 'Total Skills';
@override
String get statsTotalEconomy => 'Total Economy';
@override
String get notifyLevelUpLabel => 'LEVEL UP';
@override
String get notifyQuestDoneLabel => 'QUEST DONE';
@override
String get notifyActClearLabel => 'ACT CLEAR';
@override
String get notifyNewSpellLabel => 'NEW SPELL';
@override
String get notifyNewItemLabel => 'NEW ITEM';
@override
String get notifyBossSlainLabel => 'BOSS SLAIN';
@override
String get notifySavedLabel => 'SAVED';
@override
String get notifyInfoLabel => 'INFO';
@override
String get notifyWarningLabel => 'WARNING';
}

View File

@@ -9,78 +9,78 @@ class L10nJa extends L10n {
L10nJa([String locale = 'ja']) : super(locale);
@override
String get appTitle => 'ASCII NEVER DIE';
String get appTitle => 'アスキー ネバー ダイ';
@override
String get tagNoNetwork => 'No network';
String get tagNoNetwork => 'オフライン';
@override
String get tagIdleRpg => 'Idle RPG loop';
String get tagIdleRpg => '放置型RPG';
@override
String get tagLocalSaves => 'Local saves';
String get tagLocalSaves => 'ローカル保存';
@override
String get newCharacter => 'New character';
String get newCharacter => '新規キャラクター';
@override
String get loadSave => 'Load save';
String get loadSave => 'ロード';
@override
String get loadGame => 'Load Game';
String get loadGame => 'ゲームをロード';
@override
String get viewBuildPlan => 'View build plan';
String get viewBuildPlan => 'ビルド計画を見る';
@override
String get buildRoadmap => 'Build roadmap';
String get buildRoadmap => 'ビルドロードマップ';
@override
String get techStack => 'Tech stack';
String get techStack => '技術スタック';
@override
String get cancel => 'Cancel';
String get cancel => 'キャンセル';
@override
String get exitGame => 'Exit Game';
String get exitGame => 'ゲーム終了';
@override
String get saveProgressQuestion => 'Save your progress before leaving?';
String get saveProgressQuestion => '終了する前にセーブしますか?';
@override
String get exitWithoutSaving => 'Exit without saving';
String get exitWithoutSaving => 'セーブせずに終了';
@override
String get saveAndExit => 'Save and Exit';
String get saveAndExit => 'セーブして終了';
@override
String progressQuestTitle(String name) {
return 'ASCII NEVER DIE - $name';
return 'アスキー ネバー ダイ - $name';
}
@override
String get levelUp => 'Level Up';
String get levelUp => 'レベルアップ';
@override
String get completeQuest => 'Complete Quest';
String get completeQuest => 'クエスト完了';
@override
String get completePlot => 'Complete Plot';
String get completePlot => 'プロット完了';
@override
String get characterSheet => 'Character Sheet';
String get characterSheet => 'キャラクターシート';
@override
String get traits => 'Traits';
String get traits => '特性';
@override
String get stats => 'Stats';
String get stats => '能力値';
@override
String get experience => 'Experience';
String get experience => '経験値';
@override
String get xpNeededForNextLevel => 'XP needed for next level';
String get xpNeededForNextLevel => '次のレベルまでの必要XP';
@override
String get spellBook => 'スキル';
@@ -89,34 +89,34 @@ class L10nJa extends L10n {
String get noSpellsYet => '習得したスキルがありません';
@override
String get equipment => 'Equipment';
String get equipment => '装備';
@override
String get inventory => 'Inventory';
String get inventory => 'インベントリ';
@override
String get encumbrance => 'Encumbrance';
String get encumbrance => '積載量';
@override
String get combatLog => '戦闘ログ';
@override
String get plotDevelopment => 'Plot Development';
String get plotDevelopment => 'ストーリー進行';
@override
String get quests => 'Quests';
String get quests => 'クエスト';
@override
String get traitName => 'Name';
String get traitName => '名前';
@override
String get traitRace => 'Race';
String get traitRace => '種族';
@override
String get traitClass => 'Class';
String get traitClass => '職業';
@override
String get traitLevel => 'Level';
String get traitLevel => 'レベル';
@override
String get statStr => 'STR';
@@ -137,43 +137,43 @@ class L10nJa extends L10n {
String get statCha => 'CHA';
@override
String get statHpMax => 'HP Max';
String get statHpMax => 'HP最大';
@override
String get statMpMax => 'MP Max';
String get statMpMax => 'MP最大';
@override
String get equipWeapon => 'Weapon';
String get equipWeapon => '武器';
@override
String get equipShield => 'Shield';
String get equipShield => '';
@override
String get equipHelm => 'Helm';
String get equipHelm => '';
@override
String get equipHauberk => 'Hauberk';
String get equipHauberk => '';
@override
String get equipBrassairts => 'Brassairts';
String get equipBrassairts => '肩当て';
@override
String get equipVambraces => 'Vambraces';
String get equipVambraces => '腕甲';
@override
String get equipGauntlets => 'Gauntlets';
String get equipGauntlets => '篭手';
@override
String get equipGambeson => 'Gambeson';
String get equipGambeson => '防護服';
@override
String get equipCuisses => 'Cuisses';
String get equipCuisses => '腿当て';
@override
String get equipGreaves => 'Greaves';
String get equipGreaves => '脛当て';
@override
String get equipSollerets => 'Sollerets';
String get equipSollerets => '鉄靴';
@override
String get gold => 'コイン';
@@ -184,63 +184,63 @@ class L10nJa extends L10n {
}
@override
String get prologue => 'Prologue';
String get prologue => 'プロローグ';
@override
String actNumber(String number) {
return 'Act $number';
return '$number';
}
@override
String get noActiveQuests => 'No active quests';
String get noActiveQuests => '進行中のクエストなし';
@override
String questNumber(int number) {
return 'Quest #$number';
return 'クエスト #$number';
}
@override
String get welcomeMessage => 'ASCII NEVER DIEへようこそ';
@override
String get noSavedGames => 'No saved games found.';
String get noSavedGames => 'セーブデータがありません。';
@override
String loadError(String error) {
return 'Failed to load save file: $error';
return 'セーブファイルの読み込みに失敗しました: $error';
}
@override
String get name => 'Name';
String get name => '名前';
@override
String get generateName => 'Generate Name';
String get generateName => '名前を生成';
@override
String get total => 'Total';
String get total => '合計';
@override
String get unroll => 'Unroll';
String get unroll => '元に戻す';
@override
String get roll => 'Roll';
String get roll => 'ロール';
@override
String get race => 'Race';
String get race => '種族';
@override
String get classTitle => 'Class';
String get classTitle => '職業';
@override
String percentComplete(int percent) {
return '$percent% complete';
return '$percent% 完了';
}
@override
String get newCharacterTitle => 'ASCII NEVER DIE - New Character';
String get newCharacterTitle => 'アスキー ネバー ダイ - 新規キャラクター';
@override
String get soldButton => 'Sold!';
String get soldButton => '決定!';
@override
String get endingCongratulations => '★ おめでとうございます ★';
@@ -297,4 +297,384 @@ class L10nJa 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 => '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 => 'EXP';
@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殿堂に登録します。';
@override
String get arenaTitle => 'ローカルアリーナ';
@override
String get arenaSelectFighter => 'ファイターを選択';
@override
String get arenaEmptyTitle => 'ヒーローが不足しています';
@override
String get arenaEmptyHint => '2人以上のキャラでクリアしてください';
@override
String get arenaSetupTitle => 'アリーナ設定';
@override
String get arenaStartBattle => 'バトル開始';
@override
String get arenaBattleTitle => 'アリーナバトル';
@override
String get arenaMyEquipment => '自分の装備';
@override
String get arenaEnemyEquipment => '敵の装備';
@override
String get arenaSelected => '選択済み';
@override
String get arenaRecommended => 'おすすめ';
@override
String get arenaWeaponLocked => 'ロック';
@override
String get arenaVictory => '勝利!';
@override
String get arenaDefeat => '敗北...';
@override
String get arenaEquipmentExchange => '装備交換';
@override
String get arenaTurns => 'ターン';
@override
String get arenaWinner => '勝者';
@override
String get arenaLoser => '敗者';
@override
String arenaDefeatedIn(String winner, String loser, int turns) {
return '$winnerが$loserを$turnsターンで撃破';
}
@override
String arenaScoreGain(int score) {
return '+$score獲得予定';
}
@override
String arenaScoreLose(int score) {
return '$score損失予定';
}
@override
String get arenaEvenTrade => '等価交換';
@override
String get arenaScore => 'スコア';
@override
String get statsStatistics => '統計';
@override
String get statsSession => 'セッション';
@override
String get statsAccumulated => '累積';
@override
String get statsCombat => '戦闘';
@override
String get statsPlayTime => 'プレイ時間';
@override
String get statsMonstersKilled => '倒したモンスター';
@override
String get statsBossesDefeated => 'ボス討伐';
@override
String get statsDeaths => '死亡回数';
@override
String get statsDamage => 'ダメージ';
@override
String get statsDamageDealt => '与えたダメージ';
@override
String get statsDamageTaken => '受けたダメージ';
@override
String get statsAverageDps => '平均DPS';
@override
String get statsSkills => 'スキル';
@override
String get statsSkillsUsed => 'スキル使用';
@override
String get statsCriticalHits => 'クリティカルヒット';
@override
String get statsMaxCriticalStreak => '最大連続クリティカル';
@override
String get statsCriticalRate => 'クリティカル率';
@override
String get statsEconomy => '経済';
@override
String get statsGoldEarned => '獲得ゴールド';
@override
String get statsGoldSpent => '消費ゴールド';
@override
String get statsItemsSold => '売却アイテム';
@override
String get statsPotionsUsed => 'ポーション使用';
@override
String get statsProgress => '進行';
@override
String get statsLevelUps => 'レベルアップ';
@override
String get statsQuestsCompleted => '完了したクエスト';
@override
String get statsRecords => '記録';
@override
String get statsHighestLevel => '最高レベル';
@override
String get statsHighestGoldHeld => '最大所持ゴールド';
@override
String get statsBestCriticalStreak => '最高連続クリティカル';
@override
String get statsTotalPlay => '総プレイ';
@override
String get statsTotalPlayTime => '総プレイ時間';
@override
String get statsGamesStarted => '開始したゲーム';
@override
String get statsGamesCompleted => 'クリアしたゲーム';
@override
String get statsCompletionRate => 'クリア率';
@override
String get statsTotalCombat => '総戦闘';
@override
String get statsTotalDeaths => '総死亡';
@override
String get statsTotalLevelUps => '総レベルアップ';
@override
String get statsTotalDamage => '総ダメージ';
@override
String get statsTotalSkills => '総スキル';
@override
String get statsTotalEconomy => '総経済';
@override
String get notifyLevelUpLabel => 'レベルアップ';
@override
String get notifyQuestDoneLabel => 'クエスト完了';
@override
String get notifyActClearLabel => '幕完了';
@override
String get notifyNewSpellLabel => '新しいスキル';
@override
String get notifyNewItemLabel => '新しいアイテム';
@override
String get notifyBossSlainLabel => 'ボス撃破';
@override
String get notifySavedLabel => 'セーブ済み';
@override
String get notifyInfoLabel => '情報';
@override
String get notifyWarningLabel => '警告';
}

View File

@@ -220,7 +220,7 @@ class L10nKo extends L10n {
String get total => '합계';
@override
String get unroll => '펼치';
String get unroll => '되돌리';
@override
String get roll => '굴리기';
@@ -297,4 +297,384 @@ 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명예의 전당에 등록합니다.';
@override
String get arenaTitle => '로컬 아레나';
@override
String get arenaSelectFighter => '전사를 선택하세요';
@override
String get arenaEmptyTitle => '영웅이 부족합니다';
@override
String get arenaEmptyHint => '2명 이상 캐릭터로 클리어하세요';
@override
String get arenaSetupTitle => '아레나 설정';
@override
String get arenaStartBattle => '전투 시작';
@override
String get arenaBattleTitle => '아레나 전투';
@override
String get arenaMyEquipment => '내 장비';
@override
String get arenaEnemyEquipment => '상대 장비';
@override
String get arenaSelected => '선택됨';
@override
String get arenaRecommended => '추천';
@override
String get arenaWeaponLocked => '잠김';
@override
String get arenaVictory => '승리!';
@override
String get arenaDefeat => '패배...';
@override
String get arenaEquipmentExchange => '장비 교환';
@override
String get arenaTurns => '';
@override
String get arenaWinner => '승자';
@override
String get arenaLoser => '패자';
@override
String arenaDefeatedIn(String winner, String loser, int turns) {
return '$winner이(가) $loser을(를) $turns턴 만에 격파';
}
@override
String arenaScoreGain(int score) {
return '+$score 획득 예정';
}
@override
String arenaScoreLose(int score) {
return '$score 손실 예정';
}
@override
String get arenaEvenTrade => '등가 교환';
@override
String get arenaScore => '점수';
@override
String get statsStatistics => '통계';
@override
String get statsSession => '세션';
@override
String get statsAccumulated => '누적';
@override
String get statsCombat => '전투';
@override
String get statsPlayTime => '플레이 시간';
@override
String get statsMonstersKilled => '처치한 몬스터';
@override
String get statsBossesDefeated => '보스 처치';
@override
String get statsDeaths => '사망 횟수';
@override
String get statsDamage => '데미지';
@override
String get statsDamageDealt => '입힌 데미지';
@override
String get statsDamageTaken => '받은 데미지';
@override
String get statsAverageDps => '평균 DPS';
@override
String get statsSkills => '스킬';
@override
String get statsSkillsUsed => '스킬 사용';
@override
String get statsCriticalHits => '크리티컬 히트';
@override
String get statsMaxCriticalStreak => '최대 연속 크리티컬';
@override
String get statsCriticalRate => '크리티컬 비율';
@override
String get statsEconomy => '경제';
@override
String get statsGoldEarned => '획득 골드';
@override
String get statsGoldSpent => '소비 골드';
@override
String get statsItemsSold => '판매 아이템';
@override
String get statsPotionsUsed => '물약 사용';
@override
String get statsProgress => '진행';
@override
String get statsLevelUps => '레벨업';
@override
String get statsQuestsCompleted => '완료한 퀘스트';
@override
String get statsRecords => '기록';
@override
String get statsHighestLevel => '최고 레벨';
@override
String get statsHighestGoldHeld => '최대 보유 골드';
@override
String get statsBestCriticalStreak => '최고 연속 크리티컬';
@override
String get statsTotalPlay => '총 플레이';
@override
String get statsTotalPlayTime => '총 플레이 시간';
@override
String get statsGamesStarted => '시작한 게임';
@override
String get statsGamesCompleted => '클리어한 게임';
@override
String get statsCompletionRate => '클리어율';
@override
String get statsTotalCombat => '총 전투';
@override
String get statsTotalDeaths => '총 사망';
@override
String get statsTotalLevelUps => '총 레벨업';
@override
String get statsTotalDamage => '총 데미지';
@override
String get statsTotalSkills => '총 스킬';
@override
String get statsTotalEconomy => '총 경제';
@override
String get notifyLevelUpLabel => '레벨 업';
@override
String get notifyQuestDoneLabel => '퀘스트 완료';
@override
String get notifyActClearLabel => '막 완료';
@override
String get notifyNewSpellLabel => '새 주문';
@override
String get notifyNewItemLabel => '새 아이템';
@override
String get notifyBossSlainLabel => '보스 처치';
@override
String get notifySavedLabel => '저장됨';
@override
String get notifyInfoLabel => '정보';
@override
String get notifyWarningLabel => '경고';
}

View File

@@ -1,300 +0,0 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Chinese (`zh`).
class L10nZh extends L10n {
L10nZh([String locale = 'zh']) : super(locale);
@override
String get appTitle => 'ASCII NEVER DIE';
@override
String get tagNoNetwork => 'No network';
@override
String get tagIdleRpg => 'Idle RPG loop';
@override
String get tagLocalSaves => 'Local saves';
@override
String get newCharacter => 'New character';
@override
String get loadSave => 'Load save';
@override
String get loadGame => 'Load Game';
@override
String get viewBuildPlan => 'View build plan';
@override
String get buildRoadmap => 'Build roadmap';
@override
String get techStack => 'Tech stack';
@override
String get cancel => 'Cancel';
@override
String get exitGame => 'Exit Game';
@override
String get saveProgressQuestion => 'Save your progress before leaving?';
@override
String get exitWithoutSaving => 'Exit without saving';
@override
String get saveAndExit => 'Save and Exit';
@override
String progressQuestTitle(String name) {
return 'ASCII NEVER DIE - $name';
}
@override
String get levelUp => 'Level Up';
@override
String get completeQuest => 'Complete Quest';
@override
String get completePlot => 'Complete Plot';
@override
String get characterSheet => 'Character Sheet';
@override
String get traits => 'Traits';
@override
String get stats => 'Stats';
@override
String get experience => 'Experience';
@override
String get xpNeededForNextLevel => 'XP needed for next level';
@override
String get spellBook => '技能';
@override
String get noSpellsYet => '暂无技能';
@override
String get equipment => 'Equipment';
@override
String get inventory => 'Inventory';
@override
String get encumbrance => 'Encumbrance';
@override
String get combatLog => '战斗日志';
@override
String get plotDevelopment => 'Plot Development';
@override
String get quests => 'Quests';
@override
String get traitName => 'Name';
@override
String get traitRace => 'Race';
@override
String get traitClass => 'Class';
@override
String get traitLevel => 'Level';
@override
String get statStr => 'STR';
@override
String get statCon => 'CON';
@override
String get statDex => 'DEX';
@override
String get statInt => 'INT';
@override
String get statWis => 'WIS';
@override
String get statCha => 'CHA';
@override
String get statHpMax => 'HP Max';
@override
String get statMpMax => 'MP Max';
@override
String get equipWeapon => 'Weapon';
@override
String get equipShield => 'Shield';
@override
String get equipHelm => 'Helm';
@override
String get equipHauberk => 'Hauberk';
@override
String get equipBrassairts => 'Brassairts';
@override
String get equipVambraces => 'Vambraces';
@override
String get equipGauntlets => 'Gauntlets';
@override
String get equipGambeson => 'Gambeson';
@override
String get equipCuisses => 'Cuisses';
@override
String get equipGreaves => 'Greaves';
@override
String get equipSollerets => 'Sollerets';
@override
String get gold => 'Gold';
@override
String goldAmount(int amount) {
return 'Gold: $amount';
}
@override
String get prologue => 'Prologue';
@override
String actNumber(String number) {
return 'Act $number';
}
@override
String get noActiveQuests => 'No active quests';
@override
String questNumber(int number) {
return 'Quest #$number';
}
@override
String get welcomeMessage => '欢迎来到ASCII NEVER DIE';
@override
String get noSavedGames => 'No saved games found.';
@override
String loadError(String error) {
return 'Failed to load save file: $error';
}
@override
String get name => 'Name';
@override
String get generateName => 'Generate Name';
@override
String get total => 'Total';
@override
String get unroll => 'Unroll';
@override
String get roll => 'Roll';
@override
String get race => 'Race';
@override
String get classTitle => 'Class';
@override
String percentComplete(int percent) {
return '$percent% complete';
}
@override
String get newCharacterTitle => 'ASCII NEVER DIE - New Character';
@override
String get soldButton => 'Sold!';
@override
String get endingCongratulations => '★ 恭喜通关 ★';
@override
String get endingGameComplete => '您已完成游戏!';
@override
String get endingTheHero => '英雄';
@override
String endingLevelFormat(int level) {
return '等级 $level';
}
@override
String get endingJourneyStats => '冒险记录';
@override
String get endingMonstersSlain => '击败的怪物';
@override
String get endingQuestsCompleted => '完成的任务';
@override
String get endingPlayTime => '游戏时间';
@override
String get endingFinalStats => '最终属性';
@override
String get endingCredits => '制作人员';
@override
String get endingThankYou => '感谢您的游玩!';
@override
String get endingLegendLivesOn => '您的传奇将永远流传...';
@override
String get endingHallOfFameLine1 => '您的英雄事迹';
@override
String get endingHallOfFameLine2 => '将被铭记于荣誉殿堂';
@override
String get endingHallOfFameButton => '荣誉殿堂';
@override
String get endingSkip => '跳过';
@override
String get endingTapToSkip => '点击跳过';
@override
String get endingHoldToSpeedUp => '长按加速滚动';
}

View File

@@ -1,97 +0,0 @@
{
"@@locale": "zh",
"appTitle": "ASCII NEVER DIE",
"tagNoNetwork": "No network",
"tagIdleRpg": "Idle RPG loop",
"tagLocalSaves": "Local saves",
"newCharacter": "New character",
"loadSave": "Load save",
"loadGame": "Load Game",
"viewBuildPlan": "View build plan",
"buildRoadmap": "Build roadmap",
"techStack": "Tech stack",
"cancel": "Cancel",
"exitGame": "Exit Game",
"saveProgressQuestion": "Save your progress before leaving?",
"exitWithoutSaving": "Exit without saving",
"saveAndExit": "Save and Exit",
"progressQuestTitle": "ASCII NEVER DIE - {name}",
"levelUp": "Level Up",
"completeQuest": "Complete Quest",
"completePlot": "Complete Plot",
"characterSheet": "Character Sheet",
"traits": "Traits",
"stats": "Stats",
"experience": "Experience",
"xpNeededForNextLevel": "XP needed for next level",
"spellBook": "技能",
"noSpellsYet": "暂无技能",
"equipment": "Equipment",
"inventory": "Inventory",
"encumbrance": "Encumbrance",
"combatLog": "战斗日志",
"plotDevelopment": "Plot Development",
"quests": "Quests",
"traitName": "Name",
"traitRace": "Race",
"traitClass": "Class",
"traitLevel": "Level",
"statStr": "STR",
"statCon": "CON",
"statDex": "DEX",
"statInt": "INT",
"statWis": "WIS",
"statCha": "CHA",
"statHpMax": "HP Max",
"statMpMax": "MP Max",
"equipWeapon": "Weapon",
"equipShield": "Shield",
"equipHelm": "Helm",
"equipHauberk": "Hauberk",
"equipBrassairts": "Brassairts",
"equipVambraces": "Vambraces",
"equipGauntlets": "Gauntlets",
"equipGambeson": "Gambeson",
"equipCuisses": "Cuisses",
"equipGreaves": "Greaves",
"equipSollerets": "Sollerets",
"gold": "Gold",
"goldAmount": "Gold: {amount}",
"prologue": "Prologue",
"actNumber": "Act {number}",
"noActiveQuests": "No active quests",
"questNumber": "Quest #{number}",
"welcomeMessage": "欢迎来到ASCII NEVER DIE",
"noSavedGames": "No saved games found.",
"loadError": "Failed to load save file: {error}",
"name": "Name",
"generateName": "Generate Name",
"total": "Total",
"unroll": "Unroll",
"roll": "Roll",
"race": "Race",
"classTitle": "Class",
"percentComplete": "{percent}% complete",
"newCharacterTitle": "ASCII NEVER DIE - New Character",
"soldButton": "Sold!",
"endingCongratulations": "★ 恭喜通关 ★",
"endingGameComplete": "您已完成游戏!",
"endingTheHero": "英雄",
"endingLevelFormat": "等级 {level}",
"endingJourneyStats": "冒险记录",
"endingMonstersSlain": "击败的怪物",
"endingQuestsCompleted": "完成的任务",
"endingPlayTime": "游戏时间",
"endingFinalStats": "最终属性",
"endingCredits": "制作人员",
"endingThankYou": "感谢您的游玩!",
"endingLegendLivesOn": "您的传奇将永远流传...",
"endingHallOfFameLine1": "您的英雄事迹",
"endingHallOfFameLine2": "将被铭记于荣誉殿堂",
"endingHallOfFameButton": "荣誉殿堂",
"endingSkip": "跳过",
"endingTapToSkip": "点击跳过",
"endingHoldToSpeedUp": "长按加速滚动"
}

View File

@@ -3,7 +3,11 @@ 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/audio/audio_service.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/core/engine/ad_service.dart';
import 'package:asciineverdie/src/core/engine/debug_settings_service.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart';
import 'package:asciineverdie/src/app_theme.dart';
import 'package:asciineverdie/src/splash_screen.dart';
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
import 'package:asciineverdie/src/core/engine/progress_service.dart';
import 'package:asciineverdie/src/core/engine/reward_service.dart';
@@ -23,6 +27,7 @@ import 'package:asciineverdie/src/features/game/game_session_controller.dart';
import 'package:asciineverdie/src/features/game/widgets/notification_overlay.dart';
import 'package:asciineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart';
import 'package:asciineverdie/src/features/new_character/new_character_screen.dart';
import 'package:asciineverdie/src/features/settings/settings_screen.dart';
class AskiiNeverDieApp extends StatefulWidget {
const AskiiNeverDieApp({super.key});
@@ -44,7 +49,8 @@ class SavedGamePreview {
final String actName;
}
class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
class _AskiiNeverDieAppState extends State<AskiiNeverDieApp>
with WidgetsBindingObserver {
late final GameSessionController _controller;
late final NotificationService _notificationService;
late final SettingsRepository _settingsRepository;
@@ -55,12 +61,15 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
bool _isCheckingSave = true;
bool _hasSave = false;
SavedGamePreview? _savedGamePreview;
ThemeMode _themeMode = ThemeMode.system;
HallOfFame _hallOfFame = HallOfFame.empty();
Locale? _locale; // 사용자 선택 로케일 (null이면 시스템 기본값)
bool _isAdRemovalPurchased = false;
String? _removeAdsPrice;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
const config = PqConfig();
final mutations = GameMutations(config);
final rewards = RewardService(mutations, config);
@@ -81,12 +90,33 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
// 초기 설정 및 오디오 서비스 로드
_loadSettings();
_audioService.init();
// IAP 서비스 초기화
_initIAP();
// 세이브 파일 존재 여부 확인
_checkForExistingSave();
// 명예의 전당 로드
_loadHallOfFame();
}
/// IAP 및 광고 서비스 초기화
Future<void> _initIAP() async {
await IAPService.instance.initialize();
await AdService.instance.initialize();
_updateIAPState();
}
/// IAP 상태 업데이트 (구매 여부, 가격)
void _updateIAPState() {
if (mounted) {
setState(() {
_isAdRemovalPurchased = IAPService.instance.isAdRemovalPurchased;
_removeAdsPrice = IAPService.instance.isStoreAvailable
? IAPService.instance.removeAdsPrice
: null;
});
}
}
/// 명예의 전당 로드
Future<void> _loadHallOfFame() async {
final hallOfFame = await _hallOfFameStorage.load();
@@ -99,16 +129,26 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
/// 저장된 설정 불러오기
Future<void> _loadSettings() async {
final themeMode = await _settingsRepository.loadThemeMode();
// 디버그 설정 먼저 초기화 (광고/IAP 시뮬레이션 설정 동기화)
await DebugSettingsService.instance.initialize();
final localeCode = await _settingsRepository.loadLocale();
if (mounted) {
setState(() => _themeMode = themeMode);
setState(() {
// 저장된 로케일이 있으면 적용
if (localeCode != null) {
_locale = Locale(localeCode);
game_l10n.setGameLocale(localeCode);
}
});
}
}
/// 테마 모드 변경
void _changeThemeMode(ThemeMode mode) {
setState(() => _themeMode = mode);
_settingsRepository.saveThemeMode(mode);
/// 로케일 변경
void _changeLocale(String localeCode) {
setState(() {
_locale = Locale(localeCode);
});
}
/// 세이브 파일 존재 여부 확인 및 미리보기 정보 로드
@@ -118,7 +158,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
if (exists) {
// 세이브 파일에서 미리보기 정보 추출
final (outcome, state, _) = await _controller.saveManager.loadState();
final (outcome, state, _, _) = await _controller.saveManager.loadState();
if (outcome.success && state != null) {
final actName = _getActName(state.progress.plotStageCount);
preview = SavedGamePreview(
@@ -135,8 +175,11 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
_savedGamePreview = preview;
_isCheckingSave = false;
});
// 세이브 확인 완료 후 타이틀 BGM 재생
_audioService.playBgm('title');
// 세이브 확인 완료 후 타이틀 BGM 재생 (앱이 포그라운드일 때만)
final lifecycleState = WidgetsBinding.instance.lifecycleState;
if (lifecycleState == AppLifecycleState.resumed) {
_audioService.playBgm('title');
}
}
}
@@ -155,279 +198,29 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
_notificationService.dispose();
_audioService.dispose();
super.dispose();
}
/// 라이트 테마 (Classic Parchment 스타일)
ThemeData get _lightTheme => ThemeData(
colorScheme: RetroColors.lightColorScheme,
scaffoldBackgroundColor: const Color(0xFFFAF4ED),
useMaterial3: true,
// 카드/다이얼로그 레트로 배경
cardColor: const Color(0xFFF2E8DC),
dialogTheme: const DialogThemeData(
backgroundColor: Color(0xFFF2E8DC),
titleTextStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFB8860B),
),
),
// 앱바 레트로 스타일
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFFF2E8DC),
foregroundColor: Color(0xFF1F1F28),
titleTextStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFB8860B),
),
),
// 버튼 테마
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFE8DDD0),
foregroundColor: const Color(0xFF1F1F28),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFB8860B),
side: const BorderSide(color: Color(0xFFB8860B), width: 2),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFB8860B),
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF4A4458),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF4A4458),
),
),
),
// 텍스트 테마
textTheme: const TextTheme(
headlineLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 20,
color: Color(0xFFB8860B),
),
headlineMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: Color(0xFFB8860B),
),
headlineSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFB8860B),
),
titleLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFF1F1F28),
),
titleMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
titleSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
bodyLarge: TextStyle(fontSize: 18, color: Color(0xFF1F1F28)),
bodyMedium: TextStyle(fontSize: 17, color: Color(0xFF1F1F28)),
bodySmall: TextStyle(fontSize: 15, color: Color(0xFF1F1F28)),
labelLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
labelMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
labelSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: Color(0xFF1F1F28),
),
),
// 칩 테마
chipTheme: const ChipThemeData(
backgroundColor: Color(0xFFE8DDD0),
labelStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFF1F1F28),
),
side: BorderSide(color: Color(0xFF8B7355)),
),
// 리스트 타일 테마
listTileTheme: const ListTileThemeData(
textColor: Color(0xFF1F1F28),
iconColor: Color(0xFFB8860B),
),
// 프로그레스 인디케이터
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: Color(0xFFB8860B),
linearTrackColor: Color(0xFFD4C4B0),
),
);
/// 다크 테마 (Dark Fantasy 스타일)
ThemeData get _darkTheme => ThemeData(
colorScheme: RetroColors.darkColorScheme,
scaffoldBackgroundColor: RetroColors.deepBrown,
useMaterial3: true,
// 카드/다이얼로그 레트로 배경
cardColor: RetroColors.darkBrown,
dialogTheme: const DialogThemeData(
backgroundColor: Color(0xFF24283B),
titleTextStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFE0AF68),
),
),
// 앱바 레트로 스타일
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF24283B),
foregroundColor: Color(0xFFC0CAF5),
titleTextStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFE0AF68),
),
),
// 버튼 테마 (inherit: false로 애니메이션 lerp 오류 방지)
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF3D4260),
foregroundColor: const Color(0xFFC0CAF5),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFE0AF68),
side: const BorderSide(color: Color(0xFFE0AF68), width: 2),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFE0AF68),
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFC0CAF5),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
),
),
// 텍스트 테마
textTheme: const TextTheme(
headlineLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 20,
color: Color(0xFFE0AF68),
),
headlineMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: Color(0xFFE0AF68),
),
headlineSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFE0AF68),
),
titleLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFC0CAF5),
),
titleMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
titleSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
bodyLarge: TextStyle(fontSize: 18, color: Color(0xFFC0CAF5)),
bodyMedium: TextStyle(fontSize: 17, color: Color(0xFFC0CAF5)),
bodySmall: TextStyle(fontSize: 15, color: Color(0xFFC0CAF5)),
labelLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
labelMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
labelSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: Color(0xFFC0CAF5),
),
),
// 칩 테마
chipTheme: const ChipThemeData(
backgroundColor: Color(0xFF2A2E3F),
labelStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
side: BorderSide(color: Color(0xFF545C7E)),
),
// 리스트 타일 테마
listTileTheme: const ListTileThemeData(
textColor: Color(0xFFC0CAF5),
iconColor: Color(0xFFE0AF68),
),
// 프로그레스 인디케이터
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: Color(0xFFE0AF68),
linearTrackColor: Color(0xFF3B4261),
),
);
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
// 앱이 백그라운드로 내려가면 오디오 정지
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive) {
_audioService.pauseAll();
} else if (state == AppLifecycleState.resumed) {
_audioService.resumeAll().then((_) {
// 복귀 후 BGM이 없고 시작 화면이면 타이틀 BGM 재생
if (_audioService.currentBgm == null && !_isCheckingSave) {
_audioService.playBgm('title');
}
});
}
}
@override
Widget build(BuildContext context) {
@@ -436,9 +229,8 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
debugShowCheckedModeBanner: false,
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
theme: _lightTheme,
darkTheme: _darkTheme,
themeMode: _themeMode,
locale: _locale, // 사용자 선택 로케일 (null이면 시스템 기본값)
theme: buildAppTheme(),
navigatorObservers: [_routeObserver],
builder: (context, child) {
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
@@ -457,7 +249,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
Widget _buildHomeScreen() {
// 세이브 확인 중이면 로딩 스플래시 표시
if (_isCheckingSave) {
return const _SplashScreen();
return const SplashScreen();
}
return FrontScreen(
@@ -465,13 +257,19 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
onLoadSave: _loadSave,
onHallOfFame: _navigateToHallOfFame,
onLocalArena: _navigateToArena,
onSettings: _showSettings,
onPurchaseRemoveAds: _purchaseRemoveAds,
onRestorePurchase: _restorePurchase,
hasSaveFile: _hasSave,
savedGamePreview: _savedGamePreview,
hallOfFameCount: _hallOfFame.count,
isAdRemovalPurchased: _isAdRemovalPurchased,
removeAdsPrice: _removeAdsPrice,
routeObserver: _routeObserver,
onRefresh: () {
_checkForExistingSave();
_loadHallOfFame();
_updateIAPState();
},
);
}
@@ -546,8 +344,6 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
controller: _controller,
audioService: _audioService,
forceCarouselLayout: testMode,
currentThemeMode: _themeMode,
onThemeModeChange: _changeThemeMode,
),
),
);
@@ -563,8 +359,6 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
audioService: _audioService,
// 디버그 모드로 저장된 게임 로드 시 캐로셀 레이아웃 강제
forceCarouselLayout: _controller.cheatsEnabled,
currentThemeMode: _themeMode,
onThemeModeChange: _changeThemeMode,
),
),
)
@@ -602,135 +396,65 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
_audioService.playBgm('title');
});
}
}
/// 스플래시 화면 (세이브 파일 확인 중) - 레트로 스타일
class _SplashScreen extends StatelessWidget {
const _SplashScreen();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: RetroColors.deepBrown,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 타이틀 로고
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: RetroColors.panelBg,
border: Border.all(color: RetroColors.gold, width: 3),
),
child: Column(
children: [
// 아이콘
const Icon(
Icons.auto_awesome,
size: 32,
color: RetroColors.gold,
),
const SizedBox(height: 12),
// 타이틀
const Text(
'ASCII',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 22,
color: RetroColors.gold,
shadows: [
Shadow(
color: RetroColors.goldDark,
offset: Offset(2, 2),
),
],
),
),
const SizedBox(height: 4),
const Text(
'NEVER DIE',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: RetroColors.cream,
shadows: [
Shadow(color: RetroColors.brown, offset: Offset(1, 1)),
],
),
),
],
),
),
const SizedBox(height: 32),
// 레트로 로딩 바
SizedBox(width: 160, child: _RetroLoadingBar()),
],
),
),
/// 설정 화면 표시 (모달 바텀시트)
void _showSettings(BuildContext context) {
SettingsScreen.show(
context,
settingsRepository: _settingsRepository,
onLocaleChange: _changeLocale,
onBgmVolumeChange: _audioService.setBgmVolume,
onSfxVolumeChange: _audioService.setSfxVolume,
);
}
}
/// 레트로 스타일 로딩 바 (애니메이션)
class _RetroLoadingBar extends StatefulWidget {
@override
State<_RetroLoadingBar> createState() => _RetroLoadingBarState();
}
/// 광고 제거 구매
Future<void> _purchaseRemoveAds(BuildContext context) async {
final result = await IAPService.instance.purchaseRemoveAds();
_updateIAPState();
class _RetroLoadingBarState extends State<_RetroLoadingBar>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
if (!context.mounted) return;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
)..repeat();
switch (result) {
case IAPResult.success:
case IAPResult.debugSimulated:
_notificationService.showInfo(game_l10n.iapPurchaseSuccess);
case IAPResult.alreadyPurchased:
_notificationService.showInfo(game_l10n.iapAlreadyPurchased);
case IAPResult.cancelled:
// 취소는 무시
break;
case IAPResult.storeUnavailable:
_notificationService.showWarning(game_l10n.iapStoreUnavailable);
case IAPResult.productNotFound:
case IAPResult.failed:
_notificationService.showWarning(game_l10n.iapPurchaseFailed);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// 구매 복원
Future<void> _restorePurchase(BuildContext context) async {
final result = await IAPService.instance.restorePurchases();
_updateIAPState();
@override
Widget build(BuildContext context) {
const segmentCount = 10;
if (!context.mounted) return;
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
// 웨이브 효과: 각 세그먼트가 순차적으로 켜지고 꺼짐
return Container(
height: 16,
decoration: BoxDecoration(
color: RetroColors.panelBg,
border: Border.all(color: RetroColors.panelBorderOuter, width: 2),
),
child: Row(
children: List.generate(segmentCount, (index) {
// 웨이브 패턴 계산
final progress = _controller.value * segmentCount;
final distance = (index - progress).abs();
final isLit = distance < 2 || (segmentCount - distance) < 2;
final opacity = isLit ? 1.0 : 0.2;
return Expanded(
child: Container(
margin: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: RetroColors.gold.withValues(alpha: opacity),
),
),
);
}),
),
);
},
);
switch (result) {
case IAPResult.success:
case IAPResult.debugSimulated:
if (_isAdRemovalPurchased) {
_notificationService.showInfo(game_l10n.iapRestoreSuccess);
} else {
_notificationService.showInfo(game_l10n.iapRestoreFailed);
}
case IAPResult.storeUnavailable:
_notificationService.showWarning(game_l10n.iapStoreUnavailable);
case IAPResult.alreadyPurchased:
case IAPResult.cancelled:
case IAPResult.productNotFound:
case IAPResult.failed:
_notificationService.showWarning(game_l10n.iapRestoreFailed);
}
}
}

137
lib/src/app_theme.dart Normal file
View File

@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 앱 테마 (Dark Fantasy 스타일)
ThemeData buildAppTheme() => ThemeData(
colorScheme: RetroColors.darkColorScheme,
scaffoldBackgroundColor: RetroColors.deepBrown,
useMaterial3: true,
// 카드/다이얼로그 레트로 배경
cardColor: RetroColors.darkBrown,
dialogTheme: const DialogThemeData(
backgroundColor: Color(0xFF24283B),
titleTextStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFE0AF68),
),
),
// 앱바 레트로 스타일
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF24283B),
foregroundColor: Color(0xFFC0CAF5),
titleTextStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFE0AF68),
),
),
// 버튼 테마 (inherit: false로 애니메이션 lerp 오류 방지)
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF3D4260),
foregroundColor: const Color(0xFFC0CAF5),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFE0AF68),
side: const BorderSide(color: Color(0xFFE0AF68), width: 2),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFE0AF68),
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFC0CAF5),
textStyle: const TextStyle(
inherit: false,
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
),
),
// 텍스트 테마
textTheme: const TextTheme(
headlineLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 20,
color: Color(0xFFE0AF68),
),
headlineMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: Color(0xFFE0AF68),
),
headlineSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFE0AF68),
),
titleLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
color: Color(0xFFC0CAF5),
),
titleMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
titleSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
bodyLarge: TextStyle(fontSize: 18, color: Color(0xFFC0CAF5)),
bodyMedium: TextStyle(fontSize: 17, color: Color(0xFFC0CAF5)),
bodySmall: TextStyle(fontSize: 15, color: Color(0xFFC0CAF5)),
labelLarge: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
labelMedium: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
labelSmall: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: Color(0xFFC0CAF5),
),
),
// 칩 테마
chipTheme: const ChipThemeData(
backgroundColor: Color(0xFF2A2E3F),
labelStyle: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: Color(0xFFC0CAF5),
),
side: BorderSide(color: Color(0xFF545C7E)),
),
// 리스트 타일 테마
listTileTheme: const ListTileThemeData(
textColor: Color(0xFFC0CAF5),
iconColor: Color(0xFFE0AF68),
),
// 프로그레스 인디케이터
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: Color(0xFFE0AF68),
linearTrackColor: Color(0xFF3B4261),
),
);

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,9 @@ class AudioService {
// 오디오 일시정지 상태 (앱 백그라운드 시)
bool _isPaused = false;
// 일시정지 전 재생 중이던 BGM (복귀 시 재개용)
String? _pausedBgm;
// BGM 작업 진행 중 여부 (동시 호출 방지)
bool _isBgmBusy = false;
@@ -357,17 +360,24 @@ class AudioService {
/// 전체 오디오 일시정지 (앱 백그라운드 시)
Future<void> pauseAll() async {
_isPaused = true;
_pausedBgm = _currentBgm; // 복귀 시 재개를 위해 저장
try {
await _staticBgmPlayer?.stop();
} catch (_) {}
_currentBgm = null;
debugPrint('[AudioService] All audio paused');
debugPrint('[AudioService] All audio paused (was playing: $_pausedBgm)');
}
/// 전체 오디오 재개 (앱 포그라운드 복귀 시)
Future<void> resumeAll() async {
_isPaused = false;
debugPrint('[AudioService] Audio resumed');
// 일시정지 전 재생 중이던 BGM 재개
if (_pausedBgm != null) {
final bgmToResume = _pausedBgm!;
_pausedBgm = null;
await playBgm(bgmToResume);
}
}
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -1,7 +1,7 @@
import 'dart:math' as math;
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/animation/monster_size.dart';
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
import 'package:asciineverdie/src/core/model/combat_state.dart';
import 'package:asciineverdie/src/core/model/combat_stats.dart';
@@ -16,9 +16,7 @@ import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
///
/// ProgressService에서 추출된 Act 완료, 보스 생성 등의 로직 담당.
class ActProgressionService {
const ActProgressionService({
required this.config,
});
const ActProgressionService({required this.config});
final PqConfig config;
@@ -187,6 +185,7 @@ class ActProgressionService {
level: bossLevel,
atk: (bossStats.atk * 1.5).round(), // Boss 보정 (1.5배)
def: (bossStats.def * 1.5).round(),
magDef: (bossStats.def * 1.8).round(), // 보스 마법 방어 (물리 대비 1.2배)
hpMax: (bossStats.hp * 2.0).round(), // HP는 2.0배 (보스다운 전투 시간)
hpCurrent: (bossStats.hp * 2.0).round(),
criRate: 0.05,

View File

@@ -0,0 +1,394 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart';
/// 광고 타입
enum AdType {
/// 부활용 리워드 광고 (30초)
rewardRevive,
/// 캐릭터 생성 되돌리기용 리워드 광고 (30초)
rewardUndo,
/// 굴리기 충전용 인터스티셜 광고 (6초)
interstitialRoll,
/// 속도업용 인터스티셜 광고 (6초)
interstitialSpeed,
}
/// 광고 결과
enum AdResult {
/// 광고 시청 완료 (보상 지급)
completed,
/// 광고 시청 취소/스킵
cancelled,
/// 광고 로드 실패
failed,
/// 디버그 모드에서 광고 스킵
debugSkipped,
}
/// 광고 서비스
///
/// AdMob 리워드/인터스티셜 광고 로드 및 표시를 관리합니다.
/// 디버그 모드에서는 광고 ON/OFF 토글이 가능합니다.
class AdService {
AdService._();
static AdService? _instance;
/// 싱글톤 인스턴스
static AdService get instance {
_instance ??= AdService._();
return _instance!;
}
// ===========================================================================
// 광고 단위 ID
// ===========================================================================
// ─────────────────────────────────────────────────────────────────────────
// 테스트 광고 ID (Google 공식 테스트 ID)
// ─────────────────────────────────────────────────────────────────────────
static const String _testRewardedAndroid =
'ca-app-pub-3940256099942544/5224354917';
static const String _testRewardedIos =
'ca-app-pub-3940256099942544/1712485313';
static const String _testInterstitialAndroid =
'ca-app-pub-3940256099942544/1033173712';
static const String _testInterstitialIos =
'ca-app-pub-3940256099942544/4411468910';
// ─────────────────────────────────────────────────────────────────────────
// 프로덕션 광고 ID (AdMob 콘솔에서 생성 후 교체)
// ─────────────────────────────────────────────────────────────────────────
static const String _prodRewardedAndroid =
'ca-app-pub-6691216385521068/3457464395'; // Android 리워드 광고
static const String _prodRewardedIos =
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // TODO: iOS 리워드 광고 ID 교체
static const String _prodInterstitialAndroid =
'ca-app-pub-6691216385521068/1625507977'; // Android 인터스티셜 광고
static const String _prodInterstitialIos =
'ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX'; // TODO: iOS 인터스티셜 광고 ID 교체
/// 리워드 광고 단위 ID (릴리즈 빌드: 프로덕션 ID, 디버그 빌드: 테스트 ID)
String get _rewardAdUnitId {
if (Platform.isAndroid) {
return kReleaseMode ? _prodRewardedAndroid : _testRewardedAndroid;
} else if (Platform.isIOS) {
return kReleaseMode ? _prodRewardedIos : _testRewardedIos;
}
return '';
}
/// 인터스티셜 광고 단위 ID (릴리즈 빌드: 프로덕션 ID, 디버그 빌드: 테스트 ID)
String get _interstitialAdUnitId {
if (Platform.isAndroid) {
return kReleaseMode ? _prodInterstitialAndroid : _testInterstitialAndroid;
} else if (Platform.isIOS) {
return kReleaseMode ? _prodInterstitialIos : _testInterstitialIos;
}
return '';
}
// ===========================================================================
// 상태
// ===========================================================================
bool _isInitialized = false;
/// 로드된 리워드 광고
RewardedAd? _rewardedAd;
/// 로드된 인터스티셜 광고
InterstitialAd? _interstitialAd;
/// 리워드 광고 로딩 중 여부
bool _isLoadingRewardedAd = false;
/// 인터스티셜 광고 로딩 중 여부
bool _isLoadingInterstitialAd = false;
// ===========================================================================
// 초기화
// ===========================================================================
/// AdMob SDK 초기화
Future<void> initialize() async {
if (_isInitialized) return;
// 모바일 플랫폼에서만 초기화
if (!Platform.isAndroid && !Platform.isIOS) {
debugPrint('[AdService] Non-mobile platform, skipping initialization');
return;
}
await MobileAds.instance.initialize();
_isInitialized = true;
debugPrint('[AdService] Initialized');
// 초기 광고 로드
_loadRewardedAd();
_loadInterstitialAd();
}
// ===========================================================================
// 광고 스킵 판정
// ===========================================================================
/// 광고를 스킵할지 여부
///
/// 스킵 조건:
/// - 비모바일 플랫폼 (macOS, Windows, Linux, Web)
/// - IAP로 광고 제거 구매 완료 (디버그 시뮬레이션 포함)
bool get _shouldSkipAd {
// 웹에서는 항상 스킵
if (kIsWeb) return true;
// 비모바일 플랫폼(데스크톱)에서는 항상 스킵
if (!Platform.isAndroid && !Platform.isIOS) {
return true;
}
// IAP 광고 제거 구매 시 스킵 (디버그 시뮬레이션 포함)
if (IAPService.instance.isAdRemovalPurchased) return true;
return false;
}
// ===========================================================================
// 리워드 광고
// ===========================================================================
/// 리워드 광고 로드
void _loadRewardedAd() {
if (_isLoadingRewardedAd || _rewardedAd != null) return;
if (!_isInitialized) return;
_isLoadingRewardedAd = true;
debugPrint('[AdService] Loading rewarded ad...');
RewardedAd.load(
adUnitId: _rewardAdUnitId,
request: const AdRequest(),
rewardedAdLoadCallback: RewardedAdLoadCallback(
onAdLoaded: (ad) {
_rewardedAd = ad;
_isLoadingRewardedAd = false;
debugPrint('[AdService] Rewarded ad loaded');
},
onAdFailedToLoad: (error) {
_isLoadingRewardedAd = false;
debugPrint(
'[AdService] Rewarded ad failed to load: ${error.message}',
);
},
),
);
}
/// 리워드 광고 준비 여부
bool get isRewardedAdReady => _rewardedAd != null || _shouldSkipAd;
/// 리워드 광고 표시
///
/// [adType] 광고 타입 (로깅용)
/// [onRewarded] 보상 지급 콜백
/// Returns: 광고 결과
Future<AdResult> showRewardedAd({
required AdType adType,
required void Function() onRewarded,
}) async {
// 디버그 모드에서 광고 스킵
if (_shouldSkipAd) {
debugPrint('[AdService] Debug: Skipping $adType ad');
onRewarded();
return AdResult.debugSkipped;
}
// 광고가 로드되지 않은 경우
if (_rewardedAd == null) {
debugPrint('[AdService] Rewarded ad not ready');
_loadRewardedAd();
return AdResult.failed;
}
final ad = _rewardedAd!;
_rewardedAd = null;
// Completer를 사용하여 광고 종료까지 대기
final completer = Completer<AdResult>();
var rewarded = false;
// 광고 표시 전 전체 화면 모드로 전환 (상단 UI 숨김)
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) {
debugPrint('[AdService] Rewarded ad dismissed');
// 광고 종료 후 UI 복원
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
ad.dispose();
_loadRewardedAd(); // 다음 광고 미리 로드
// 보상 수령 여부에 따라 결과 반환
if (!completer.isCompleted) {
completer.complete(
rewarded ? AdResult.completed : AdResult.cancelled,
);
}
},
onAdFailedToShowFullScreenContent: (ad, error) {
debugPrint('[AdService] Rewarded ad failed to show: ${error.message}');
// 광고 실패 시에도 UI 복원
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
ad.dispose();
_loadRewardedAd();
if (!completer.isCompleted) {
completer.complete(AdResult.failed);
}
},
);
await ad.show(
onUserEarnedReward: (ad, reward) {
debugPrint('[AdService] User earned reward: ${reward.amount}');
rewarded = true;
onRewarded();
},
);
// 광고가 종료될 때까지 대기
return completer.future;
}
// ===========================================================================
// 인터스티셜 광고
// ===========================================================================
/// 인터스티셜 광고 로드
void _loadInterstitialAd() {
if (_isLoadingInterstitialAd || _interstitialAd != null) return;
if (!_isInitialized) return;
_isLoadingInterstitialAd = true;
debugPrint('[AdService] Loading interstitial ad...');
InterstitialAd.load(
adUnitId: _interstitialAdUnitId,
request: const AdRequest(),
adLoadCallback: InterstitialAdLoadCallback(
onAdLoaded: (ad) {
_interstitialAd = ad;
_isLoadingInterstitialAd = false;
debugPrint('[AdService] Interstitial ad loaded');
},
onAdFailedToLoad: (error) {
_isLoadingInterstitialAd = false;
debugPrint(
'[AdService] Interstitial ad failed to load: ${error.message}',
);
},
),
);
}
/// 인터스티셜 광고 준비 여부
bool get isInterstitialAdReady => _interstitialAd != null || _shouldSkipAd;
/// 인터스티셜 광고 표시
///
/// [adType] 광고 타입 (로깅용)
/// [onComplete] 광고 완료 콜백 (보상 지급)
/// Returns: 광고 결과
Future<AdResult> showInterstitialAd({
required AdType adType,
required void Function() onComplete,
}) async {
// 디버그 모드에서 광고 스킵
if (_shouldSkipAd) {
debugPrint('[AdService] Debug: Skipping $adType ad');
onComplete();
return AdResult.debugSkipped;
}
// 광고가 로드되지 않은 경우
if (_interstitialAd == null) {
debugPrint('[AdService] Interstitial ad not ready');
_loadInterstitialAd();
return AdResult.failed;
}
final ad = _interstitialAd!;
_interstitialAd = null;
// Completer를 사용하여 광고 종료까지 대기
final completer = Completer<AdResult>();
// 광고 표시 전 전체 화면 모드로 전환 (상단 UI 숨김)
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) {
debugPrint('[AdService] Interstitial ad dismissed');
// 광고 종료 후 UI 복원
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
ad.dispose();
onComplete();
_loadInterstitialAd(); // 다음 광고 미리 로드
if (!completer.isCompleted) {
completer.complete(AdResult.completed);
}
},
onAdFailedToShowFullScreenContent: (ad, error) {
debugPrint(
'[AdService] Interstitial ad failed to show: ${error.message}',
);
// 광고 실패 시에도 UI 복원
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
ad.dispose();
_loadInterstitialAd();
if (!completer.isCompleted) {
completer.complete(AdResult.failed);
}
},
);
await ad.show();
// 광고가 종료될 때까지 대기
return completer.future;
}
// ===========================================================================
// 정리
// ===========================================================================
/// 리소스 해제
void dispose() {
_rewardedAd?.dispose();
_rewardedAd = null;
_interstitialAd?.dispose();
_interstitialAd = null;
debugPrint('[AdService] Disposed');
}
}

View File

@@ -0,0 +1,497 @@
import 'package:asciineverdie/data/skill_data.dart';
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
import 'package:asciineverdie/src/core/engine/skill_service.dart';
import 'package:asciineverdie/src/core/model/arena_match.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/model/monster_combat_stats.dart';
import 'package:asciineverdie/src/core/model/skill.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
/// 아레나 전투 시뮬레이터
///
/// ArenaService에서 분리된 전투 시뮬레이션 로직.
/// 스킬 시스템을 포함한 턴 기반 전투를 처리한다.
class ArenaCombatSimulator {
ArenaCombatSimulator({required DeterministicRandom rng})
: _rng = rng,
_skillService = SkillService(rng: rng);
final DeterministicRandom _rng;
final SkillService _skillService;
/// 전투 시뮬레이션 (애니메이션용 스트림)
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) async* {
final challengerStats = match.challenger.finalStats;
final opponentStats = match.opponent.finalStats;
if (challengerStats == null || opponentStats == null) {
return;
}
final calculator = CombatCalculator(rng: _rng);
// 스킬 ID 목록 로드
var challengerSkillIds = _getSkillIdsFromEntry(match.challenger);
var opponentSkillIds = _getSkillIdsFromEntry(match.opponent);
if (challengerSkillIds.isEmpty) {
challengerSkillIds = SkillData.defaultSkillIds;
}
if (opponentSkillIds.isEmpty) {
opponentSkillIds = SkillData.defaultSkillIds;
}
// 스킬 시스템 상태 초기화
var challengerSkillSystem = SkillSystemState.empty();
var opponentSkillSystem = SkillSystemState.empty();
// DOT 및 디버프 추적
var challengerDoTs = <DotEffect>[];
var opponentDoTs = <DotEffect>[];
var challengerDebuffs = <ActiveBuff>[];
var opponentDebuffs = <ActiveBuff>[];
var playerCombatStats = challengerStats.copyWith(
hpCurrent: challengerStats.hpMax,
mpCurrent: challengerStats.mpMax,
);
var opponentCombatStats = opponentStats.copyWith(
hpCurrent: opponentStats.hpMax,
mpCurrent: opponentStats.mpMax,
);
int playerAccum = 0;
int opponentAccum = 0;
int elapsedMs = 0;
const tickMs = 200;
int turns = 0;
// 초기 상태 전송
yield ArenaCombatTurn(
challengerHp: playerCombatStats.hpCurrent,
opponentHp: opponentCombatStats.hpCurrent,
challengerHpMax: playerCombatStats.hpMax,
opponentHpMax: opponentCombatStats.hpMax,
challengerMp: playerCombatStats.mpCurrent,
opponentMp: opponentCombatStats.mpCurrent,
challengerMpMax: playerCombatStats.mpMax,
opponentMpMax: opponentCombatStats.mpMax,
);
while (playerCombatStats.isAlive && opponentCombatStats.hpCurrent > 0) {
playerAccum += tickMs;
opponentAccum += tickMs;
elapsedMs += tickMs;
// 스킬 시스템 시간 업데이트
challengerSkillSystem = challengerSkillSystem.copyWith(
elapsedMs: elapsedMs,
);
opponentSkillSystem = opponentSkillSystem.copyWith(elapsedMs: elapsedMs);
int? challengerDamage;
int? opponentDamage;
bool isChallengerCritical = false;
bool isOpponentCritical = false;
bool isChallengerEvaded = false;
bool isOpponentEvaded = false;
bool isChallengerBlocked = false;
bool isOpponentBlocked = false;
String? challengerSkillUsed;
String? opponentSkillUsed;
int? challengerHealAmount;
int? opponentHealAmount;
// DOT 틱 처리
final dotResult = _processDotTicks(
challengerDoTs: challengerDoTs,
opponentDoTs: opponentDoTs,
playerStats: playerCombatStats,
opponentStats: opponentCombatStats,
tickMs: tickMs,
);
challengerDoTs = dotResult.challengerDoTs;
opponentDoTs = dotResult.opponentDoTs;
playerCombatStats = dotResult.playerStats;
opponentCombatStats = dotResult.opponentStats;
// 만료된 디버프 정리
challengerDebuffs = challengerDebuffs
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
.toList();
opponentDebuffs = opponentDebuffs
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
.toList();
// 도전자 턴
if (playerAccum >= playerCombatStats.attackDelayMs) {
playerAccum = 0;
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
opponentCombatStats,
match.opponent.characterName,
);
final turnResult = _processCharacterTurn(
player: playerCombatStats,
target: opponentCombatStats,
targetMonster: opponentMonsterStats,
targetName: match.opponent.characterName,
entry: match.challenger,
skillIds: challengerSkillIds,
skillSystem: challengerSkillSystem,
activeDoTs: challengerDoTs,
activeDebuffs: opponentDebuffs,
calculator: calculator,
elapsedMs: elapsedMs,
);
playerCombatStats = turnResult.player;
opponentCombatStats = turnResult.target;
challengerSkillSystem = turnResult.skillSystem;
challengerDoTs = turnResult.activeDoTs;
opponentDebuffs = turnResult.targetDebuffs;
challengerDamage = turnResult.damage;
isChallengerCritical = turnResult.isCritical;
isOpponentEvaded = turnResult.isTargetEvaded;
challengerSkillUsed = turnResult.skillUsed;
challengerHealAmount = turnResult.healAmount;
}
// 상대 턴
if (opponentCombatStats.hpCurrent > 0 &&
opponentAccum >= opponentCombatStats.attackDelayMs) {
opponentAccum = 0;
var challengerMonsterStats = MonsterCombatStats.fromCombatStats(
playerCombatStats,
match.challenger.characterName,
);
final turnResult = _processCharacterTurn(
player: opponentCombatStats,
target: playerCombatStats,
targetMonster: challengerMonsterStats,
targetName: match.challenger.characterName,
entry: match.opponent,
skillIds: opponentSkillIds,
skillSystem: opponentSkillSystem,
activeDoTs: opponentDoTs,
activeDebuffs: challengerDebuffs,
calculator: calculator,
elapsedMs: elapsedMs,
);
opponentCombatStats = turnResult.player;
playerCombatStats = turnResult.target;
opponentSkillSystem = turnResult.skillSystem;
opponentDoTs = turnResult.activeDoTs;
challengerDebuffs = turnResult.targetDebuffs;
opponentDamage = turnResult.damage;
isOpponentCritical = turnResult.isCritical;
isChallengerEvaded = turnResult.isTargetEvaded;
isChallengerBlocked = turnResult.isTargetBlocked;
opponentSkillUsed = turnResult.skillUsed;
opponentHealAmount = turnResult.healAmount;
}
// 액션이 발생했을 때만 턴 전송
final hasAction =
challengerDamage != null ||
opponentDamage != null ||
challengerHealAmount != null ||
opponentHealAmount != null ||
challengerSkillUsed != null ||
opponentSkillUsed != null;
if (hasAction) {
turns++;
yield ArenaCombatTurn(
challengerDamage: challengerDamage,
opponentDamage: opponentDamage,
challengerHp: playerCombatStats.hpCurrent,
opponentHp: opponentCombatStats.hpCurrent,
challengerHpMax: playerCombatStats.hpMax,
opponentHpMax: opponentCombatStats.hpMax,
challengerMp: playerCombatStats.mpCurrent,
opponentMp: opponentCombatStats.mpCurrent,
challengerMpMax: playerCombatStats.mpMax,
opponentMpMax: opponentCombatStats.mpMax,
isChallengerCritical: isChallengerCritical,
isOpponentCritical: isOpponentCritical,
isChallengerEvaded: isChallengerEvaded,
isOpponentEvaded: isOpponentEvaded,
isChallengerBlocked: isChallengerBlocked,
isOpponentBlocked: isOpponentBlocked,
challengerSkillUsed: challengerSkillUsed,
opponentSkillUsed: opponentSkillUsed,
challengerHealAmount: challengerHealAmount,
opponentHealAmount: opponentHealAmount,
);
await Future<void>.delayed(const Duration(milliseconds: 100));
}
if (turns > 1000) break;
}
}
/// DOT 틱 처리 (양측)
({
List<DotEffect> challengerDoTs,
List<DotEffect> opponentDoTs,
CombatStats playerStats,
CombatStats opponentStats,
})
_processDotTicks({
required List<DotEffect> challengerDoTs,
required List<DotEffect> opponentDoTs,
required CombatStats playerStats,
required CombatStats opponentStats,
required int tickMs,
}) {
var updatedPlayerStats = playerStats;
var updatedOpponentStats = opponentStats;
// 도전자 -> 상대에게 적용된 DOT
var dotDamageToOpponent = 0;
final updatedChallengerDoTs = <DotEffect>[];
for (final dot in challengerDoTs) {
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
if (ticksTriggered > 0) {
dotDamageToOpponent += dot.damagePerTick * ticksTriggered;
}
if (updatedDot.isActive) updatedChallengerDoTs.add(updatedDot);
}
if (dotDamageToOpponent > 0 && updatedOpponentStats.hpCurrent > 0) {
updatedOpponentStats = updatedOpponentStats.copyWith(
hpCurrent: (updatedOpponentStats.hpCurrent - dotDamageToOpponent).clamp(
0,
updatedOpponentStats.hpMax,
),
);
}
// 상대 -> 도전자에게 적용된 DOT
var dotDamageToChallenger = 0;
final updatedOpponentDoTs = <DotEffect>[];
for (final dot in opponentDoTs) {
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
if (ticksTriggered > 0) {
dotDamageToChallenger += dot.damagePerTick * ticksTriggered;
}
if (updatedDot.isActive) updatedOpponentDoTs.add(updatedDot);
}
if (dotDamageToChallenger > 0 && updatedPlayerStats.isAlive) {
updatedPlayerStats = updatedPlayerStats.copyWith(
hpCurrent: (updatedPlayerStats.hpCurrent - dotDamageToChallenger).clamp(
0,
updatedPlayerStats.hpMax,
),
);
}
return (
challengerDoTs: updatedChallengerDoTs,
opponentDoTs: updatedOpponentDoTs,
playerStats: updatedPlayerStats,
opponentStats: updatedOpponentStats,
);
}
/// 캐릭터 턴 처리 (도전자/상대 공통)
({
CombatStats player,
CombatStats target,
SkillSystemState skillSystem,
List<DotEffect> activeDoTs,
List<ActiveBuff> targetDebuffs,
int? damage,
bool isCritical,
bool isTargetEvaded,
bool isTargetBlocked,
String? skillUsed,
int? healAmount,
})
_processCharacterTurn({
required CombatStats player,
required CombatStats target,
required MonsterCombatStats targetMonster,
required String targetName,
required HallOfFameEntry entry,
required List<String> skillIds,
required SkillSystemState skillSystem,
required List<DotEffect> activeDoTs,
required List<ActiveBuff> activeDebuffs,
required CombatCalculator calculator,
required int elapsedMs,
}) {
int? damage;
bool isCritical = false;
bool isTargetEvaded = false;
bool isTargetBlocked = false;
String? skillUsed;
int? healAmount;
var updatedPlayer = player;
var updatedTarget = target;
var updatedSkillSystem = skillSystem;
var updatedDoTs = [...activeDoTs];
var updatedDebuffs = [...activeDebuffs];
final selectedSkill = _skillService.selectAutoSkill(
player: updatedPlayer,
monster: targetMonster,
skillSystem: updatedSkillSystem,
availableSkillIds: skillIds,
activeDoTs: updatedDoTs,
activeDebuffs: updatedDebuffs,
);
if (selectedSkill != null && selectedSkill.isAttack) {
final skillRank = _getSkillRankFromEntry(entry, selectedSkill.id);
final skillResult = _skillService.useAttackSkillWithRank(
skill: selectedSkill,
player: updatedPlayer,
monster: targetMonster,
skillSystem: updatedSkillSystem,
rank: skillRank,
);
updatedPlayer = skillResult.updatedPlayer;
updatedTarget = updatedTarget.copyWith(
hpCurrent: skillResult.updatedMonster.hpCurrent,
);
updatedSkillSystem = skillResult.updatedSkillSystem;
skillUsed = selectedSkill.name;
damage = skillResult.result.damage;
} else if (selectedSkill != null && selectedSkill.isDot) {
final skillResult = _skillService.useDotSkill(
skill: selectedSkill,
player: updatedPlayer,
skillSystem: updatedSkillSystem,
playerInt: updatedPlayer.atk ~/ 10,
playerWis: updatedPlayer.def ~/ 10,
);
updatedPlayer = skillResult.updatedPlayer;
updatedSkillSystem = skillResult.updatedSkillSystem;
if (skillResult.dotEffect != null) {
updatedDoTs.add(skillResult.dotEffect!);
}
skillUsed = selectedSkill.name;
} else if (selectedSkill != null && selectedSkill.isHeal) {
final skillResult = _skillService.useHealSkill(
skill: selectedSkill,
player: updatedPlayer,
skillSystem: updatedSkillSystem,
);
updatedPlayer = skillResult.updatedPlayer;
updatedSkillSystem = skillResult.updatedSkillSystem;
skillUsed = selectedSkill.name;
healAmount = skillResult.result.healedAmount;
} else if (selectedSkill != null && selectedSkill.isBuff) {
final skillResult = _skillService.useBuffSkill(
skill: selectedSkill,
player: updatedPlayer,
skillSystem: updatedSkillSystem,
);
updatedPlayer = skillResult.updatedPlayer;
updatedSkillSystem = skillResult.updatedSkillSystem;
skillUsed = selectedSkill.name;
} else if (selectedSkill != null && selectedSkill.isDebuff) {
final skillResult = _skillService.useDebuffSkill(
skill: selectedSkill,
player: updatedPlayer,
skillSystem: updatedSkillSystem,
currentDebuffs: updatedDebuffs,
);
updatedPlayer = skillResult.updatedPlayer;
updatedSkillSystem = skillResult.updatedSkillSystem;
final debuffEffect = skillResult.debuffEffect;
if (debuffEffect != null) {
updatedDebuffs =
updatedDebuffs
.where((ActiveBuff d) => d.effect.id != debuffEffect.effect.id)
.toList()
..add(debuffEffect);
}
skillUsed = selectedSkill.name;
} else {
// 일반 공격
final opponentMonsterStats = MonsterCombatStats.fromCombatStats(
updatedTarget,
targetName,
);
final result = calculator.playerAttackMonster(
attacker: updatedPlayer,
defender: opponentMonsterStats,
);
updatedTarget = updatedTarget.copyWith(
hpCurrent: result.updatedDefender.hpCurrent,
);
if (result.result.isHit) {
damage = result.result.damage;
isCritical = result.result.isCritical;
} else {
isTargetEvaded = true;
}
}
return (
player: updatedPlayer,
target: updatedTarget,
skillSystem: updatedSkillSystem,
activeDoTs: updatedDoTs,
targetDebuffs: updatedDebuffs,
damage: damage,
isCritical: isCritical,
isTargetEvaded: isTargetEvaded,
isTargetBlocked: isTargetBlocked,
skillUsed: skillUsed,
healAmount: healAmount,
);
}
/// 스킬 ID 목록 추출 (HallOfFameEntry에서)
List<String> _getSkillIdsFromEntry(HallOfFameEntry entry) {
final skillData = entry.finalSkills;
if (skillData == null || skillData.isEmpty) return [];
final skillIds = <String>[];
for (final data in skillData) {
final skillName = data['name'];
if (skillName != null) {
final skill = SkillData.getSkillBySpellName(skillName);
if (skill != null) {
skillIds.add(skill.id);
}
}
}
return skillIds;
}
/// 스킬 랭크 조회 (HallOfFameEntry의 finalSkills에서)
int _getSkillRankFromEntry(HallOfFameEntry entry, String skillId) {
final skill = SkillData.getSkillById(skillId);
if (skill == null) return 1;
final skillData = entry.finalSkills;
if (skillData == null || skillData.isEmpty) return 1;
for (final data in skillData) {
if (data['name'] == skill.name) {
final rankStr = data['rank'] ?? 'I';
return switch (rankStr) {
'I' => 1,
'II' => 2,
'III' => 3,
'IV' => 4,
'V' => 5,
_ => 1,
};
}
}
return 1;
}
}

View File

@@ -1,14 +1,11 @@
import 'package:asciineverdie/data/skill_data.dart';
import 'package:asciineverdie/src/core/engine/arena_combat_simulator.dart';
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
import 'package:asciineverdie/src/core/engine/item_service.dart';
import 'package:asciineverdie/src/core/engine/skill_service.dart';
import 'package:asciineverdie/src/core/model/arena_match.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/game_state.dart';
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
import 'package:asciineverdie/src/core/model/skill.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
/// 아레나 서비스
@@ -23,64 +20,6 @@ class ArenaService {
final DeterministicRandom _rng;
late final SkillService _skillService = SkillService(rng: _rng);
// ============================================================================
// 스킬 시스템 헬퍼
// ============================================================================
/// HallOfFameEntry의 finalSkills에서 Skill 목록 추출
List<Skill> _getSkillsFromEntry(HallOfFameEntry entry) {
final skillData = entry.finalSkills;
if (skillData == null || skillData.isEmpty) return [];
final skills = <Skill>[];
for (final data in skillData) {
final skillName = data['name'];
if (skillName != null) {
final skill = SkillData.getSkillBySpellName(skillName);
if (skill != null) {
skills.add(skill);
}
}
}
return skills;
}
/// 스킬 ID 목록 추출 (HallOfFameEntry에서)
List<String> _getSkillIdsFromEntry(HallOfFameEntry entry) {
return _getSkillsFromEntry(entry).map((s) => s.id).toList();
}
/// 스킬 랭크 조회 (HallOfFameEntry의 finalSkills에서)
int _getSkillRankFromEntry(HallOfFameEntry entry, String skillId) {
final skill = SkillData.getSkillById(skillId);
if (skill == null) return 1;
final skillData = entry.finalSkills;
if (skillData == null || skillData.isEmpty) return 1;
for (final data in skillData) {
if (data['name'] == skill.name) {
final rankStr = data['rank'] ?? 'I';
return _romanToInt(rankStr);
}
}
return 1;
}
/// 로마 숫자 → 정수 변환
int _romanToInt(String roman) {
return switch (roman) {
'I' => 1,
'II' => 2,
'III' => 3,
'IV' => 4,
'V' => 5,
_ => 1,
};
}
// ============================================================================
// 상대 결정
// ============================================================================
@@ -230,452 +169,10 @@ class ArenaService {
/// 전투 시뮬레이션 (애니메이션용 스트림)
///
/// progress_service._processCombatTickWithSkills()와 동일한 로직 사용
/// [match] 대전 정보
/// Returns: 턴별 전투 상황 스트림
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) async* {
final calculator = CombatCalculator(rng: _rng);
final challengerStats = match.challenger.finalStats;
final opponentStats = match.opponent.finalStats;
if (challengerStats == null || opponentStats == null) {
return;
}
// 스킬 ID 목록 로드 (SkillBook과 동일한 방식)
var challengerSkillIds = _getSkillIdsFromEntry(match.challenger);
var opponentSkillIds = _getSkillIdsFromEntry(match.opponent);
// 스킬이 없으면 기본 스킬 사용
if (challengerSkillIds.isEmpty) {
challengerSkillIds = SkillData.defaultSkillIds;
}
if (opponentSkillIds.isEmpty) {
opponentSkillIds = SkillData.defaultSkillIds;
}
// 스킬 시스템 상태 초기화
var challengerSkillSystem = SkillSystemState.empty();
var opponentSkillSystem = SkillSystemState.empty();
// DOT 및 디버프 추적 (일반 전투와 동일)
var challengerDoTs = <DotEffect>[];
var opponentDoTs = <DotEffect>[];
var challengerDebuffs = <ActiveBuff>[];
var opponentDebuffs = <ActiveBuff>[];
var playerCombatStats = challengerStats.copyWith(
hpCurrent: challengerStats.hpMax,
mpCurrent: challengerStats.mpMax,
);
var opponentCombatStats = opponentStats.copyWith(
hpCurrent: opponentStats.hpMax,
mpCurrent: opponentStats.mpMax,
);
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
opponentCombatStats,
match.opponent.characterName,
);
var challengerMonsterStats = MonsterCombatStats.fromCombatStats(
playerCombatStats,
match.challenger.characterName,
);
int playerAccum = 0;
int opponentAccum = 0;
int elapsedMs = 0;
const tickMs = 200;
int turns = 0;
// 초기 상태 전송
yield ArenaCombatTurn(
challengerHp: playerCombatStats.hpCurrent,
opponentHp: opponentCombatStats.hpCurrent,
challengerHpMax: playerCombatStats.hpMax,
opponentHpMax: opponentCombatStats.hpMax,
challengerMp: playerCombatStats.mpCurrent,
opponentMp: opponentCombatStats.mpCurrent,
challengerMpMax: playerCombatStats.mpMax,
opponentMpMax: opponentCombatStats.mpMax,
);
while (playerCombatStats.isAlive && opponentCombatStats.hpCurrent > 0) {
playerAccum += tickMs;
opponentAccum += tickMs;
elapsedMs += tickMs;
// 스킬 시스템 시간 업데이트
challengerSkillSystem = challengerSkillSystem.copyWith(
elapsedMs: elapsedMs,
);
opponentSkillSystem = opponentSkillSystem.copyWith(elapsedMs: elapsedMs);
int? challengerDamage;
int? opponentDamage;
bool isChallengerCritical = false;
bool isOpponentCritical = false;
bool isChallengerEvaded = false;
bool isOpponentEvaded = false;
bool isChallengerBlocked = false;
bool isOpponentBlocked = false;
String? challengerSkillUsed;
String? opponentSkillUsed;
int? challengerHealAmount;
int? opponentHealAmount;
// =========================================================================
// DOT 틱 처리 (도전자 → 상대에게 적용된 DOT)
// =========================================================================
var dotDamageToOpponent = 0;
final updatedChallengerDoTs = <DotEffect>[];
for (final dot in challengerDoTs) {
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
if (ticksTriggered > 0) {
dotDamageToOpponent += dot.damagePerTick * ticksTriggered;
}
if (updatedDot.isActive) {
updatedChallengerDoTs.add(updatedDot);
}
}
challengerDoTs = updatedChallengerDoTs;
if (dotDamageToOpponent > 0 && opponentCombatStats.hpCurrent > 0) {
opponentCombatStats = opponentCombatStats.copyWith(
hpCurrent: (opponentCombatStats.hpCurrent - dotDamageToOpponent)
.clamp(0, opponentCombatStats.hpMax),
);
}
// DOT 틱 처리 (상대 → 도전자에게 적용된 DOT)
var dotDamageToChallenger = 0;
final updatedOpponentDoTs = <DotEffect>[];
for (final dot in opponentDoTs) {
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
if (ticksTriggered > 0) {
dotDamageToChallenger += dot.damagePerTick * ticksTriggered;
}
if (updatedDot.isActive) {
updatedOpponentDoTs.add(updatedDot);
}
}
opponentDoTs = updatedOpponentDoTs;
if (dotDamageToChallenger > 0 && playerCombatStats.isAlive) {
playerCombatStats = playerCombatStats.copyWith(
hpCurrent: (playerCombatStats.hpCurrent - dotDamageToChallenger)
.clamp(0, playerCombatStats.hpMax),
);
}
// =========================================================================
// 만료된 디버프 정리
// =========================================================================
challengerDebuffs = challengerDebuffs
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
.toList();
opponentDebuffs = opponentDebuffs
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
.toList();
// =========================================================================
// 도전자 턴 (selectAutoSkill 사용 - 일반 전투와 동일)
// =========================================================================
if (playerAccum >= playerCombatStats.attackDelayMs) {
playerAccum = 0;
// 상대 몬스터 스탯 동기화
opponentMonsterStats = MonsterCombatStats.fromCombatStats(
opponentCombatStats,
match.opponent.characterName,
);
// 스킬 자동 선택 (progress_service와 동일한 로직)
final selectedSkill = _skillService.selectAutoSkill(
player: playerCombatStats,
monster: opponentMonsterStats,
skillSystem: challengerSkillSystem,
availableSkillIds: challengerSkillIds,
activeDoTs: challengerDoTs,
activeDebuffs: opponentDebuffs,
);
if (selectedSkill != null && selectedSkill.isAttack) {
// 스킬 랭크 조회 및 적용
final skillRank = _getSkillRankFromEntry(
match.challenger,
selectedSkill.id,
);
final skillResult = _skillService.useAttackSkillWithRank(
skill: selectedSkill,
player: playerCombatStats,
monster: opponentMonsterStats,
skillSystem: challengerSkillSystem,
rank: skillRank,
);
playerCombatStats = skillResult.updatedPlayer;
opponentCombatStats = opponentCombatStats.copyWith(
hpCurrent: skillResult.updatedMonster.hpCurrent,
);
challengerSkillSystem = skillResult.updatedSkillSystem;
challengerSkillUsed = selectedSkill.name;
challengerDamage = skillResult.result.damage;
} else if (selectedSkill != null && selectedSkill.isDot) {
// DOT 스킬 사용
final skillResult = _skillService.useDotSkill(
skill: selectedSkill,
player: playerCombatStats,
skillSystem: challengerSkillSystem,
playerInt: playerCombatStats.atk ~/ 10,
playerWis: playerCombatStats.def ~/ 10,
);
playerCombatStats = skillResult.updatedPlayer;
challengerSkillSystem = skillResult.updatedSkillSystem;
if (skillResult.dotEffect != null) {
challengerDoTs.add(skillResult.dotEffect!);
}
challengerSkillUsed = selectedSkill.name;
} else if (selectedSkill != null && selectedSkill.isHeal) {
// 회복 스킬 사용
final skillResult = _skillService.useHealSkill(
skill: selectedSkill,
player: playerCombatStats,
skillSystem: challengerSkillSystem,
);
playerCombatStats = skillResult.updatedPlayer;
challengerSkillSystem = skillResult.updatedSkillSystem;
challengerSkillUsed = selectedSkill.name;
challengerHealAmount = skillResult.result.healedAmount;
} else if (selectedSkill != null && selectedSkill.isBuff) {
// 버프 스킬 사용
final skillResult = _skillService.useBuffSkill(
skill: selectedSkill,
player: playerCombatStats,
skillSystem: challengerSkillSystem,
);
playerCombatStats = skillResult.updatedPlayer;
challengerSkillSystem = skillResult.updatedSkillSystem;
challengerSkillUsed = selectedSkill.name;
} else if (selectedSkill != null && selectedSkill.isDebuff) {
// 디버프 스킬 사용
final skillResult = _skillService.useDebuffSkill(
skill: selectedSkill,
player: playerCombatStats,
skillSystem: challengerSkillSystem,
currentDebuffs: opponentDebuffs,
);
playerCombatStats = skillResult.updatedPlayer;
challengerSkillSystem = skillResult.updatedSkillSystem;
final debuffEffect = skillResult.debuffEffect;
if (debuffEffect != null) {
opponentDebuffs =
opponentDebuffs
.where(
(ActiveBuff d) => d.effect.id != debuffEffect.effect.id,
)
.toList()
..add(debuffEffect);
}
challengerSkillUsed = selectedSkill.name;
} else {
// 일반 공격
final result = calculator.playerAttackMonster(
attacker: playerCombatStats,
defender: opponentMonsterStats,
);
opponentCombatStats = opponentCombatStats.copyWith(
hpCurrent: result.updatedDefender.hpCurrent,
);
if (result.result.isHit) {
challengerDamage = result.result.damage;
isChallengerCritical = result.result.isCritical;
} else {
isOpponentEvaded = true;
}
}
}
// =========================================================================
// 상대 턴 (selectAutoSkill 사용 - 일반 전투와 동일)
// =========================================================================
if (opponentCombatStats.hpCurrent > 0 &&
opponentAccum >= opponentCombatStats.attackDelayMs) {
opponentAccum = 0;
// 도전자 몬스터 스탯 동기화
challengerMonsterStats = MonsterCombatStats.fromCombatStats(
playerCombatStats,
match.challenger.characterName,
);
// 스킬 자동 선택 (progress_service와 동일한 로직)
final selectedSkill = _skillService.selectAutoSkill(
player: opponentCombatStats,
monster: challengerMonsterStats,
skillSystem: opponentSkillSystem,
availableSkillIds: opponentSkillIds,
activeDoTs: opponentDoTs,
activeDebuffs: challengerDebuffs,
);
if (selectedSkill != null && selectedSkill.isAttack) {
// 스킬 랭크 조회 및 적용
final skillRank = _getSkillRankFromEntry(
match.opponent,
selectedSkill.id,
);
final skillResult = _skillService.useAttackSkillWithRank(
skill: selectedSkill,
player: opponentCombatStats,
monster: challengerMonsterStats,
skillSystem: opponentSkillSystem,
rank: skillRank,
);
opponentCombatStats = skillResult.updatedPlayer;
playerCombatStats = playerCombatStats.copyWith(
hpCurrent: skillResult.updatedMonster.hpCurrent,
);
opponentSkillSystem = skillResult.updatedSkillSystem;
opponentSkillUsed = selectedSkill.name;
opponentDamage = skillResult.result.damage;
} else if (selectedSkill != null && selectedSkill.isDot) {
// DOT 스킬 사용
final skillResult = _skillService.useDotSkill(
skill: selectedSkill,
player: opponentCombatStats,
skillSystem: opponentSkillSystem,
playerInt: opponentCombatStats.atk ~/ 10,
playerWis: opponentCombatStats.def ~/ 10,
);
opponentCombatStats = skillResult.updatedPlayer;
opponentSkillSystem = skillResult.updatedSkillSystem;
if (skillResult.dotEffect != null) {
opponentDoTs.add(skillResult.dotEffect!);
}
opponentSkillUsed = selectedSkill.name;
} else if (selectedSkill != null && selectedSkill.isHeal) {
// 회복 스킬 사용
final skillResult = _skillService.useHealSkill(
skill: selectedSkill,
player: opponentCombatStats,
skillSystem: opponentSkillSystem,
);
opponentCombatStats = skillResult.updatedPlayer;
opponentSkillSystem = skillResult.updatedSkillSystem;
opponentSkillUsed = selectedSkill.name;
opponentHealAmount = skillResult.result.healedAmount;
} else if (selectedSkill != null && selectedSkill.isBuff) {
// 버프 스킬 사용
final skillResult = _skillService.useBuffSkill(
skill: selectedSkill,
player: opponentCombatStats,
skillSystem: opponentSkillSystem,
);
opponentCombatStats = skillResult.updatedPlayer;
opponentSkillSystem = skillResult.updatedSkillSystem;
opponentSkillUsed = selectedSkill.name;
} else if (selectedSkill != null && selectedSkill.isDebuff) {
// 디버프 스킬 사용
final skillResult = _skillService.useDebuffSkill(
skill: selectedSkill,
player: opponentCombatStats,
skillSystem: opponentSkillSystem,
currentDebuffs: challengerDebuffs,
);
opponentCombatStats = skillResult.updatedPlayer;
opponentSkillSystem = skillResult.updatedSkillSystem;
final debuffEffect = skillResult.debuffEffect;
if (debuffEffect != null) {
challengerDebuffs =
challengerDebuffs
.where(
(ActiveBuff d) => d.effect.id != debuffEffect.effect.id,
)
.toList()
..add(debuffEffect);
}
opponentSkillUsed = selectedSkill.name;
} else {
// 일반 공격 (디버프 효과 적용)
var debuffedOpponent = opponentCombatStats;
if (challengerDebuffs.isNotEmpty) {
double atkMod = 0;
for (final debuff in challengerDebuffs) {
if (!debuff.isExpired(elapsedMs)) {
atkMod += debuff.effect.atkModifier;
}
}
final newAtk = (opponentCombatStats.atk * (1 + atkMod))
.round()
.clamp(opponentCombatStats.atk ~/ 10, opponentCombatStats.atk);
debuffedOpponent = opponentCombatStats.copyWith(atk: newAtk);
}
opponentMonsterStats = MonsterCombatStats.fromCombatStats(
debuffedOpponent,
match.opponent.characterName,
);
final result = calculator.monsterAttackPlayer(
attacker: opponentMonsterStats,
defender: playerCombatStats,
);
playerCombatStats = result.updatedDefender;
if (result.result.isHit) {
opponentDamage = result.result.damage;
isOpponentCritical = result.result.isCritical;
isChallengerBlocked = result.result.isBlocked;
} else {
isChallengerEvaded = true;
}
}
}
// 액션이 발생했을 때만 턴 전송
final hasAction =
challengerDamage != null ||
opponentDamage != null ||
challengerHealAmount != null ||
opponentHealAmount != null ||
challengerSkillUsed != null ||
opponentSkillUsed != null;
if (hasAction) {
turns++;
yield ArenaCombatTurn(
challengerDamage: challengerDamage,
opponentDamage: opponentDamage,
challengerHp: playerCombatStats.hpCurrent,
opponentHp: opponentCombatStats.hpCurrent,
challengerHpMax: playerCombatStats.hpMax,
opponentHpMax: opponentCombatStats.hpMax,
challengerMp: playerCombatStats.mpCurrent,
opponentMp: opponentCombatStats.mpCurrent,
challengerMpMax: playerCombatStats.mpMax,
opponentMpMax: opponentCombatStats.mpMax,
isChallengerCritical: isChallengerCritical,
isOpponentCritical: isOpponentCritical,
isChallengerEvaded: isChallengerEvaded,
isOpponentEvaded: isOpponentEvaded,
isChallengerBlocked: isChallengerBlocked,
isOpponentBlocked: isOpponentBlocked,
challengerSkillUsed: challengerSkillUsed,
opponentSkillUsed: opponentSkillUsed,
challengerHealAmount: challengerHealAmount,
opponentHealAmount: opponentHealAmount,
);
// 애니메이션을 위한 딜레이
await Future<void>.delayed(const Duration(milliseconds: 100));
}
// 무한 루프 방지
if (turns > 1000) break;
}
/// ArenaCombatSimulator에 위임하여 턴별 전투 상황을 스트림으로 반환.
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) {
final simulator = ArenaCombatSimulator(rng: _rng);
return simulator.simulateCombat(match);
}
// ============================================================================
// AI 베팅 슬롯 선택

View File

@@ -0,0 +1,295 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:asciineverdie/src/core/engine/ad_service.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
/// 캐릭터 생성 굴리기/되돌리기 서비스
///
/// 굴리기 횟수 제한과 되돌리기 기능을 관리합니다.
/// - 굴리기: 5회 (0회 시 광고 시청으로 충전)
/// - 되돌리기: 무료 유저 1회(광고), 유료 유저 3회(무료)
class CharacterRollService {
CharacterRollService._();
static CharacterRollService? _instance;
/// 싱글톤 인스턴스
static CharacterRollService get instance {
_instance ??= CharacterRollService._();
return _instance!;
}
// ===========================================================================
// 상수
// ===========================================================================
/// 저장 키
static const String _rollsRemainingKey = 'char_rolls_remaining';
/// 최대 굴리기 횟수
static const int maxRolls = 5;
/// 최대 되돌리기 횟수 (무료 유저)
static const int maxUndoFreeUser = 1;
/// 최대 되돌리기 횟수 (유료 유저)
static const int maxUndoPaidUser = 3;
// ===========================================================================
// 상태
// ===========================================================================
bool _isInitialized = false;
/// 남은 굴리기 횟수
int _rollsRemaining = maxRolls;
/// 남은 되돌리기 횟수
int _undoRemaining = maxUndoFreeUser;
/// 되돌리기용 스탯 히스토리
final List<RollSnapshot> _rollHistory = [];
// ===========================================================================
// 초기화
// ===========================================================================
/// 서비스 초기화
Future<void> initialize() async {
if (_isInitialized) return;
await _loadState();
_resetUndoForNewSession();
_isInitialized = true;
debugPrint(
'[CharacterRollService] Initialized: '
'rolls=$_rollsRemaining, undo=$_undoRemaining',
);
}
/// 저장된 상태 로드
Future<void> _loadState() async {
final prefs = await SharedPreferences.getInstance();
_rollsRemaining = prefs.getInt(_rollsRemainingKey) ?? maxRolls;
}
/// 굴리기 횟수 저장
Future<void> _saveRollsRemaining() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_rollsRemainingKey, _rollsRemaining);
debugPrint('[CharacterRollService] Saved rolls: $_rollsRemaining');
}
/// 새 세션 시작 시 되돌리기 초기화
void _resetUndoForNewSession() {
_undoRemaining = _isPaidUser ? maxUndoPaidUser : maxUndoFreeUser;
_rollHistory.clear();
}
// ===========================================================================
// 유료 사용자 확인
// ===========================================================================
/// 유료 사용자 여부
bool get _isPaidUser => IAPService.instance.isAdRemovalPurchased;
// ===========================================================================
// 굴리기
// ===========================================================================
/// 남은 굴리기 횟수
int get rollsRemaining => _rollsRemaining;
/// 굴리기 가능 여부
bool get canRoll => _rollsRemaining > 0;
/// 굴리기 실행
///
/// [currentStats] 현재 스탯 (되돌리기용 저장)
/// [currentRaceIndex] 현재 종족 인덱스
/// [currentKlassIndex] 현재 직업 인덱스
/// [currentSeed] 현재 RNG 시드
/// Returns: 굴리기 성공 여부
bool roll({
required Stats currentStats,
required int currentRaceIndex,
required int currentKlassIndex,
required int currentSeed,
}) {
if (!canRoll) {
debugPrint('[CharacterRollService] Cannot roll: no rolls remaining');
return false;
}
// 현재 상태를 히스토리에 저장
_rollHistory.insert(
0,
RollSnapshot(
stats: currentStats,
raceIndex: currentRaceIndex,
klassIndex: currentKlassIndex,
seed: currentSeed,
),
);
// 최대 히스토리 개수 제한 (되돌리기 가능 횟수만큼)
final maxHistory = _isPaidUser ? maxUndoPaidUser : maxUndoFreeUser;
while (_rollHistory.length > maxHistory) {
_rollHistory.removeLast();
}
// 굴리기 횟수 감소
_rollsRemaining--;
_saveRollsRemaining();
// 매 굴림마다 되돌리기 횟수 리셋
// - 유료 유저: 3회
// - 무료 유저: 1회 (광고 시청 필요)
_undoRemaining = _isPaidUser ? maxUndoPaidUser : maxUndoFreeUser;
debugPrint(
'[CharacterRollService] Rolled: remaining=$_rollsRemaining, '
'history=${_rollHistory.length}, undo=$_undoRemaining',
);
return true;
}
/// 광고 시청 후 굴리기 충전
Future<bool> rechargeRollsWithAd() async {
// 유료 사용자는 광고 없이 충전
if (_isPaidUser) {
_rollsRemaining = maxRolls;
await _saveRollsRemaining();
debugPrint('[CharacterRollService] Recharged (paid user): $maxRolls');
return true;
}
// 인터스티셜 광고 표시
final result = await AdService.instance.showInterstitialAd(
adType: AdType.interstitialRoll,
onComplete: () {
_rollsRemaining = maxRolls;
_saveRollsRemaining();
},
);
if (result == AdResult.completed || result == AdResult.debugSkipped) {
debugPrint('[CharacterRollService] Recharged with ad: $maxRolls');
return true;
}
debugPrint('[CharacterRollService] Recharge failed: $result');
return false;
}
// ===========================================================================
// 되돌리기
// ===========================================================================
/// 남은 되돌리기 횟수
int get undoRemaining => _undoRemaining;
/// 되돌리기 히스토리 길이
int get historyLength => _rollHistory.length;
/// 실제 사용 가능한 되돌리기 횟수
/// min(undoRemaining, historyLength)
int get availableUndos {
final available = _undoRemaining < _rollHistory.length
? _undoRemaining
: _rollHistory.length;
return available;
}
/// 되돌리기 가능 여부
bool get canUndo => availableUndos > 0;
/// 되돌리기 실행 (유료 사용자)
///
/// Returns: 복원된 스냅샷 (null이면 실패)
RollSnapshot? undoPaidUser() {
if (!_isPaidUser) return null;
if (!canUndo) return null;
final snapshot = _rollHistory.removeAt(0);
_undoRemaining--;
debugPrint(
'[CharacterRollService] Undo (paid): '
'remaining=$_undoRemaining, history=${_rollHistory.length}',
);
return snapshot;
}
/// 되돌리기 실행 (무료 사용자 - 광고 필요)
///
/// [onSuccess] 광고 시청 완료 후 콜백
Future<RollSnapshot?> undoFreeUser() async {
if (_isPaidUser) return undoPaidUser();
if (!canUndo) return null;
// 리워드 광고 표시
RollSnapshot? result;
final adResult = await AdService.instance.showRewardedAd(
adType: AdType.rewardUndo,
onRewarded: () {
if (_rollHistory.isNotEmpty) {
result = _rollHistory.removeAt(0);
_undoRemaining--;
}
},
);
if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) {
debugPrint(
'[CharacterRollService] Undo (free with ad): '
'remaining=$_undoRemaining, history=${_rollHistory.length}',
);
return result;
}
debugPrint('[CharacterRollService] Undo failed: $adResult');
return null;
}
// ===========================================================================
// 캐릭터 생성 완료
// ===========================================================================
/// 캐릭터 생성 완료 시 호출
///
/// 되돌리기 상태만 초기화 (굴리기 횟수는 유지)
void onCharacterCreated() {
_resetUndoForNewSession();
debugPrint('[CharacterRollService] Character created, undo reset');
}
/// 굴리기 횟수 완전 초기화 (디버그용)
Future<void> resetRolls() async {
_rollsRemaining = maxRolls;
await _saveRollsRemaining();
_resetUndoForNewSession();
debugPrint('[CharacterRollService] Reset all');
}
}
/// 굴리기 스냅샷 (되돌리기용)
class RollSnapshot {
const RollSnapshot({
required this.stats,
required this.raceIndex,
required this.klassIndex,
required this.seed,
});
final Stats stats;
final int raceIndex;
final int klassIndex;
final int seed;
}

View File

@@ -0,0 +1,282 @@
import 'package:flutter/foundation.dart';
import 'package:asciineverdie/data/potion_data.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/treasure_chest.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
/// 보물 상자 서비스
///
/// 상자 내용물 생성 및 오픈 로직 담당
class ChestService {
ChestService({DeterministicRandom? rng})
: _rng = rng ?? DeterministicRandom(DateTime.now().millisecondsSinceEpoch);
final DeterministicRandom _rng;
// ==========================================================================
// 상수
// ==========================================================================
/// 보상 타입별 확률 (%)
static const int _equipmentChance = 40; // 장비 40%
static const int _potionChance = 30; // 포션 30%
static const int _goldChance = 20; // 골드 20%
// 경험치 10% (나머지)
/// 골드 보상 범위 (레벨 * 배율)
static const int _goldPerLevel = 50;
static const int _goldVariance = 20;
/// 경험치 보상 범위 (레벨 * 배율)
static const int _expPerLevel = 100;
static const int _expVariance = 30;
/// 포션 수량 범위
static const int _minPotionCount = 1;
static const int _maxPotionCount = 3;
// ==========================================================================
// 상자 오픈
// ==========================================================================
/// 상자 오픈하여 보상 생성
///
/// [playerLevel] 플레이어 레벨 (보상 스케일링용)
ChestReward openChest(int playerLevel) {
final roll = _rng.nextInt(100);
if (roll < _equipmentChance) {
// 40%: 장비
return _generateEquipmentReward(playerLevel);
} else if (roll < _equipmentChance + _potionChance) {
// 30%: 포션
return _generatePotionReward(playerLevel);
} else if (roll < _equipmentChance + _potionChance + _goldChance) {
// 20%: 골드
return _generateGoldReward(playerLevel);
} else {
// 10%: 경험치
return _generateExperienceReward(playerLevel);
}
}
/// 여러 상자 오픈
List<ChestReward> openMultipleChests(int count, int playerLevel) {
final rewards = <ChestReward>[];
for (var i = 0; i < count; i++) {
rewards.add(openChest(playerLevel));
}
return rewards;
}
// ==========================================================================
// 보상 생성
// ==========================================================================
/// 장비 보상 생성
ChestReward _generateEquipmentReward(int playerLevel) {
// 랜덤 슬롯 선택
final slotIndex = _rng.nextInt(EquipmentSlot.values.length);
final slot = EquipmentSlot.values[slotIndex];
// 희귀도 결정 (상자는 좀 더 좋은 확률)
final rarity = _rollChestRarity();
// 아이템 레벨: 플레이어 레벨 ±2
final minLevel = (playerLevel - 2).clamp(1, 999);
final maxLevel = playerLevel + 2;
final itemLevel = minLevel + _rng.nextInt(maxLevel - minLevel + 1);
// 아이템 생성
final item = EquipmentItem(
name: _generateItemName(slot, rarity, itemLevel),
slot: slot,
level: itemLevel,
weight: _calculateWeight(slot, itemLevel),
stats: _generateItemStats(slot, itemLevel, rarity),
rarity: rarity,
);
debugPrint(
'[ChestService] Equipment reward: ${item.name} (${rarity.name})',
);
return ChestReward.equipment(item);
}
/// 포션 보상 생성
ChestReward _generatePotionReward(int playerLevel) {
// 레벨에 맞는 티어 선택
final tier = PotionData.tierForLevel(playerLevel);
// HP/MP 랜덤 선택
final isHp = _rng.nextInt(2) == 0;
final potion = isHp
? PotionData.getHpPotionByTier(tier)
: PotionData.getMpPotionByTier(tier);
if (potion == null) {
// 폴백: 기본 포션
return ChestReward.potion('minor_health_patch', 1);
}
// 수량 결정
final count =
_minPotionCount + _rng.nextInt(_maxPotionCount - _minPotionCount + 1);
debugPrint('[ChestService] Potion reward: ${potion.name} x$count');
return ChestReward.potion(potion.id, count);
}
/// 골드 보상 생성
ChestReward _generateGoldReward(int playerLevel) {
final baseGold = playerLevel * _goldPerLevel;
final variance = _rng.nextInt(_goldVariance * 2 + 1) - _goldVariance;
final gold = (baseGold + (baseGold * variance / 100)).round().clamp(
10,
99999,
);
debugPrint('[ChestService] Gold reward: $gold');
return ChestReward.gold(gold);
}
/// 경험치 보상 생성
ChestReward _generateExperienceReward(int playerLevel) {
final baseExp = playerLevel * _expPerLevel;
final variance = _rng.nextInt(_expVariance * 2 + 1) - _expVariance;
final exp = (baseExp + (baseExp * variance / 100)).round().clamp(
10,
999999,
);
debugPrint('[ChestService] Experience reward: $exp');
return ChestReward.experience(exp);
}
// ==========================================================================
// 헬퍼 메서드
// ==========================================================================
/// 상자 희귀도 롤 (일반 샵보다 좋은 확률)
/// Common 50%, Uncommon 30%, Rare 15%, Epic 4%, Legendary 1%
ItemRarity _rollChestRarity() {
final roll = _rng.nextInt(100);
if (roll < 50) return ItemRarity.common;
if (roll < 80) return ItemRarity.uncommon;
if (roll < 95) return ItemRarity.rare;
if (roll < 99) return ItemRarity.epic;
return ItemRarity.legendary;
}
/// 아이템 이름 생성
String _generateItemName(EquipmentSlot slot, ItemRarity rarity, int level) {
final prefix = _getRarityPrefix(rarity);
final baseName = _getSlotBaseName(slot);
final suffix = level > 10 ? ' +${level ~/ 10}' : '';
return '$prefix$baseName$suffix'.trim();
}
String _getRarityPrefix(ItemRarity rarity) {
return switch (rarity) {
ItemRarity.common => '',
ItemRarity.uncommon => 'Fine ',
ItemRarity.rare => 'Superior ',
ItemRarity.epic => 'Epic ',
ItemRarity.legendary => 'Legendary ',
};
}
String _getSlotBaseName(EquipmentSlot slot) {
return switch (slot) {
EquipmentSlot.weapon => 'Keyboard',
EquipmentSlot.shield => 'Firewall Shield',
EquipmentSlot.helm => 'Neural Headset',
EquipmentSlot.hauberk => 'Server Rack Armor',
EquipmentSlot.brassairts => 'Cable Brassairts',
EquipmentSlot.vambraces => 'USB Vambraces',
EquipmentSlot.gauntlets => 'Typing Gauntlets',
EquipmentSlot.gambeson => 'Padded Gambeson',
EquipmentSlot.cuisses => 'Circuit Cuisses',
EquipmentSlot.greaves => 'Copper Greaves',
EquipmentSlot.sollerets => 'Static Boots',
};
}
/// 스탯 생성
ItemStats _generateItemStats(
EquipmentSlot slot,
int level,
ItemRarity rarity,
) {
final multiplier = rarity.multiplier;
final baseValue = (level * multiplier).round();
return switch (slot) {
EquipmentSlot.weapon => ItemStats(
atk: baseValue * 2,
criRate: 0.01 * (level ~/ 5),
parryRate: 0.005 * level,
),
EquipmentSlot.shield => ItemStats(
def: baseValue,
blockRate: 0.02 * (level ~/ 3).clamp(1, 10),
),
EquipmentSlot.helm => ItemStats(
def: baseValue ~/ 2,
magDef: baseValue ~/ 2,
intBonus: level ~/ 10,
),
EquipmentSlot.hauberk => ItemStats(def: baseValue, hpBonus: level * 2),
EquipmentSlot.brassairts => ItemStats(
def: baseValue ~/ 2,
strBonus: level ~/ 15,
),
EquipmentSlot.vambraces => ItemStats(
def: baseValue ~/ 2,
dexBonus: level ~/ 15,
),
EquipmentSlot.gauntlets => ItemStats(
atk: baseValue ~/ 2,
def: baseValue ~/ 4,
),
EquipmentSlot.gambeson => ItemStats(
def: baseValue ~/ 2,
conBonus: level ~/ 15,
),
EquipmentSlot.cuisses => ItemStats(
def: baseValue ~/ 2,
evasion: 0.005 * level,
),
EquipmentSlot.greaves => ItemStats(
def: baseValue ~/ 2,
evasion: 0.003 * level,
),
EquipmentSlot.sollerets => ItemStats(
def: baseValue ~/ 3,
evasion: 0.002 * level,
dexBonus: level ~/ 20,
),
};
}
/// 무게 계산
int _calculateWeight(EquipmentSlot slot, int level) {
final baseWeight = switch (slot) {
EquipmentSlot.weapon => 5,
EquipmentSlot.shield => 8,
EquipmentSlot.helm => 4,
EquipmentSlot.hauberk => 15,
EquipmentSlot.brassairts => 3,
EquipmentSlot.vambraces => 3,
EquipmentSlot.gauntlets => 2,
EquipmentSlot.gambeson => 6,
EquipmentSlot.cuisses => 5,
EquipmentSlot.greaves => 4,
EquipmentSlot.sollerets => 3,
};
return baseWeight + (level ~/ 10);
}
}

View File

@@ -1,5 +1,8 @@
import 'package:asciineverdie/data/class_data.dart';
import 'package:asciineverdie/data/skill_data.dart';
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
import 'package:asciineverdie/src/core/model/class_traits.dart';
import 'package:asciineverdie/src/core/engine/player_attack_processor.dart';
import 'package:asciineverdie/src/core/engine/potion_service.dart';
import 'package:asciineverdie/src/core/engine/skill_service.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart';
@@ -68,7 +71,7 @@ class CombatTickService {
var turnsElapsed = combat.turnsElapsed;
var updatedSkillSystem = skillSystem;
var activeDoTs = [...combat.activeDoTs];
var usedPotionTypes = {...combat.usedPotionTypes};
var lastPotionUsedMs = combat.lastPotionUsedMs;
var activeDebuffs = [...combat.activeDebuffs];
PotionInventory? updatedPotionInventory;
@@ -94,25 +97,38 @@ class CombatTickService {
totalDamageDealt = dotResult.totalDamageDealt;
newEvents.addAll(dotResult.events);
// 긴급 물약 자동 사용 (HP < 30%)
// 클래스 패시브 조회 (healingBonus, firstStrikeBonus, multiAttack)
final klass = ClassData.findById(state.traits.classId);
final healingBonus =
klass?.getPassiveValue(ClassPassiveType.healingBonus) ?? 0.0;
final healingMultiplier = 1.0 + healingBonus;
final firstStrikeBonus =
klass?.getPassiveValue(ClassPassiveType.firstStrikeBonus) ?? 0.0;
final hasMultiAttack =
klass?.hasPassive(ClassPassiveType.multiAttack) ?? false;
var isFirstPlayerAttack = combat.isFirstPlayerAttack;
// 긴급 물약 자동 사용 (HP < 30% 또는 MP < 50%)
final potionResult = _tryEmergencyPotion(
playerStats: playerStats,
potionInventory: state.potionInventory,
usedPotionTypes: usedPotionTypes,
lastPotionUsedMs: lastPotionUsedMs,
playerLevel: state.traits.level,
timestamp: timestamp,
potionService: potionService,
healingMultiplier: healingMultiplier,
);
if (potionResult != null) {
playerStats = potionResult.playerStats;
usedPotionTypes = potionResult.usedPotionTypes;
lastPotionUsedMs = potionResult.lastPotionUsedMs;
updatedPotionInventory = potionResult.potionInventory;
newEvents.addAll(potionResult.events);
}
// 플레이어 공격 체크
if (playerAccumulator >= playerStats.attackDelayMs) {
final attackResult = _processPlayerAttack(
final attackProcessor = PlayerAttackProcessor(rng: rng);
final attackResult = attackProcessor.processAttack(
state: state,
playerStats: playerStats,
monsterStats: monsterStats,
@@ -123,6 +139,10 @@ class CombatTickService {
timestamp: timestamp,
calculator: calculator,
skillService: skillService,
isFirstPlayerAttack: isFirstPlayerAttack,
firstStrikeBonus: firstStrikeBonus > 0 ? firstStrikeBonus : 1.0,
hasMultiAttack: hasMultiAttack,
healingMultiplier: healingMultiplier,
);
playerStats = attackResult.playerStats;
@@ -132,6 +152,7 @@ class CombatTickService {
activeDebuffs = attackResult.activeDebuffs;
totalDamageDealt = attackResult.totalDamageDealt;
newEvents.addAll(attackResult.events);
isFirstPlayerAttack = attackResult.isFirstPlayerAttack;
playerAccumulator -= playerStats.attackDelayMs;
turnsElapsed++;
@@ -176,8 +197,9 @@ class CombatTickService {
isActive: isActive,
recentEvents: recentEvents,
activeDoTs: activeDoTs,
usedPotionTypes: usedPotionTypes,
lastPotionUsedMs: lastPotionUsedMs,
activeDebuffs: activeDebuffs,
isFirstPlayerAttack: isFirstPlayerAttack,
),
skillSystem: updatedSkillSystem,
potionInventory: updatedPotionInventory,
@@ -190,7 +212,8 @@ class CombatTickService {
MonsterCombatStats monsterStats,
int totalDamageDealt,
List<CombatEvent> events,
}) _processDotTicks({
})
_processDotTicks({
required List<DotEffect> activeDoTs,
required MonsterCombatStats monsterStats,
required int elapsedMs,
@@ -246,265 +269,105 @@ class CombatTickService {
);
}
/// 긴급 물약 자동 사용
/// 긴급 물약 자동 사용 (HP/MP 통합 글로벌 쿨타임)
({
CombatStats playerStats,
Set<PotionType> usedPotionTypes,
int lastPotionUsedMs,
PotionInventory potionInventory,
List<CombatEvent> events,
})? _tryEmergencyPotion({
})?
_tryEmergencyPotion({
required CombatStats playerStats,
required PotionInventory potionInventory,
required Set<PotionType> usedPotionTypes,
required int lastPotionUsedMs,
required int playerLevel,
required int timestamp,
required PotionService potionService,
double healingMultiplier = 1.0,
}) {
final hpRatio = playerStats.hpCurrent / playerStats.hpMax;
if (hpRatio > PotionService.emergencyHpThreshold) {
// 글로벌 쿨타임 체크
if (timestamp - lastPotionUsedMs < PotionService.globalPotionCooldownMs) {
return null;
}
final emergencyPotion = potionService.selectEmergencyHpPotion(
// 우선순위 1: HP 물약 (소모된 HP >= 물약 회복량)
final hpPotion = potionService.selectEmergencyHpPotion(
currentHp: playerStats.hpCurrent,
maxHp: playerStats.hpMax,
inventory: potionInventory,
playerLevel: playerLevel,
);
if (emergencyPotion == null || usedPotionTypes.contains(PotionType.hp)) {
return null;
if (hpPotion != null) {
final result = potionService.usePotion(
potionId: hpPotion.id,
inventory: potionInventory,
currentHp: playerStats.hpCurrent,
maxHp: playerStats.hpMax,
currentMp: playerStats.mpCurrent,
maxMp: playerStats.mpMax,
healingMultiplier: healingMultiplier,
);
if (result.success) {
return (
playerStats: playerStats.copyWith(hpCurrent: result.newHp),
lastPotionUsedMs: timestamp,
potionInventory: result.newInventory!,
events: [
CombatEvent.playerPotion(
timestamp: timestamp,
potionName: hpPotion.name,
healAmount: result.healedAmount,
isHp: true,
),
],
);
}
}
final result = potionService.usePotion(
potionId: emergencyPotion.id,
inventory: potionInventory,
currentHp: playerStats.hpCurrent,
maxHp: playerStats.hpMax,
// 우선순위 2: MP 물약 (소모된 MP >= 물약 회복량)
final mpPotion = potionService.selectEmergencyMpPotion(
currentMp: playerStats.mpCurrent,
maxMp: playerStats.mpMax,
inventory: potionInventory,
playerLevel: playerLevel,
);
if (!result.success) {
return null;
}
return (
playerStats: playerStats.copyWith(hpCurrent: result.newHp),
usedPotionTypes: {...usedPotionTypes, PotionType.hp},
potionInventory: result.newInventory!,
events: [
CombatEvent.playerPotion(
timestamp: timestamp,
potionName: emergencyPotion.name,
healAmount: result.healedAmount,
isHp: true,
),
],
);
}
/// 플레이어 공격 처리
({
CombatStats playerStats,
MonsterCombatStats monsterStats,
SkillSystemState skillSystem,
List<DotEffect> activeDoTs,
List<ActiveBuff> activeDebuffs,
int totalDamageDealt,
List<CombatEvent> events,
}) _processPlayerAttack({
required GameState state,
required CombatStats playerStats,
required MonsterCombatStats monsterStats,
required SkillSystemState updatedSkillSystem,
required List<DotEffect> activeDoTs,
required List<ActiveBuff> activeDebuffs,
required int totalDamageDealt,
required int timestamp,
required CombatCalculator calculator,
required SkillService skillService,
}) {
final events = <CombatEvent>[];
var newPlayerStats = playerStats;
var newMonsterStats = monsterStats;
var newSkillSystem = updatedSkillSystem;
var newActiveDoTs = [...activeDoTs];
var newActiveBuffs = [...activeDebuffs];
var newTotalDamageDealt = totalDamageDealt;
// 장착된 스킬 슬롯에서 사용 가능한 스킬 ID 목록 조회
var availableSkillIds = state.skillSystem.equippedSkills.allSkills
.map((s) => s.id)
.toList();
// 장착된 스킬이 없으면 기본 스킬 사용
if (availableSkillIds.isEmpty) {
availableSkillIds = SkillData.defaultSkillIds;
}
final selectedSkill = skillService.selectAutoSkill(
player: newPlayerStats,
monster: newMonsterStats,
skillSystem: newSkillSystem,
availableSkillIds: availableSkillIds,
activeDoTs: newActiveDoTs,
activeDebuffs: newActiveBuffs,
);
if (selectedSkill != null && selectedSkill.isAttack) {
// 스킬 랭크 조회
final skillRank = skillService.getSkillRankFromSkillBook(
state.skillBook,
selectedSkill.id,
if (mpPotion != null) {
final result = potionService.usePotion(
potionId: mpPotion.id,
inventory: potionInventory,
currentHp: playerStats.hpCurrent,
maxHp: playerStats.hpMax,
currentMp: playerStats.mpCurrent,
maxMp: playerStats.mpMax,
healingMultiplier: healingMultiplier,
);
// 랭크 스케일링 적용된 공격 스킬 사용
final skillResult = skillService.useAttackSkillWithRank(
skill: selectedSkill,
player: newPlayerStats,
monster: newMonsterStats,
skillSystem: newSkillSystem,
rank: skillRank,
);
newPlayerStats = skillResult.updatedPlayer;
newMonsterStats = skillResult.updatedMonster;
newTotalDamageDealt += skillResult.result.damage;
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
events.add(
CombatEvent.playerSkill(
timestamp: timestamp,
skillName: selectedSkill.name,
damage: skillResult.result.damage,
targetName: newMonsterStats.name,
attackDelayMs: newPlayerStats.attackDelayMs,
),
);
} else if (selectedSkill != null && selectedSkill.isDot) {
final skillResult = skillService.useDotSkill(
skill: selectedSkill,
player: newPlayerStats,
skillSystem: newSkillSystem,
playerInt: state.stats.intelligence,
playerWis: state.stats.wis,
);
newPlayerStats = skillResult.updatedPlayer;
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
if (skillResult.dotEffect != null) {
newActiveDoTs.add(skillResult.dotEffect!);
}
events.add(
CombatEvent.playerSkill(
timestamp: timestamp,
skillName: selectedSkill.name,
damage: skillResult.result.damage,
targetName: newMonsterStats.name,
attackDelayMs: newPlayerStats.attackDelayMs,
),
);
} else if (selectedSkill != null && selectedSkill.isHeal) {
final skillResult = skillService.useHealSkill(
skill: selectedSkill,
player: newPlayerStats,
skillSystem: newSkillSystem,
);
newPlayerStats = skillResult.updatedPlayer;
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
events.add(
CombatEvent.playerHeal(
timestamp: timestamp,
healAmount: skillResult.result.healedAmount,
skillName: selectedSkill.name,
),
);
} else if (selectedSkill != null && selectedSkill.isBuff) {
final skillResult = skillService.useBuffSkill(
skill: selectedSkill,
player: newPlayerStats,
skillSystem: newSkillSystem,
);
newPlayerStats = skillResult.updatedPlayer;
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
events.add(
CombatEvent.playerBuff(
timestamp: timestamp,
skillName: selectedSkill.name,
),
);
} else if (selectedSkill != null && selectedSkill.isDebuff) {
final skillResult = skillService.useDebuffSkill(
skill: selectedSkill,
player: newPlayerStats,
skillSystem: newSkillSystem,
currentDebuffs: newActiveBuffs,
);
newPlayerStats = skillResult.updatedPlayer;
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
if (skillResult.debuffEffect != null) {
newActiveBuffs = newActiveBuffs
.where((d) => d.effect.id != skillResult.debuffEffect!.effect.id)
.toList()
..add(skillResult.debuffEffect!);
}
events.add(
CombatEvent.playerDebuff(
timestamp: timestamp,
skillName: selectedSkill.name,
targetName: newMonsterStats.name,
),
);
} else {
// 일반 공격
final attackResult = calculator.playerAttackMonster(
attacker: newPlayerStats,
defender: newMonsterStats,
);
newMonsterStats = attackResult.updatedDefender;
newTotalDamageDealt += attackResult.result.damage;
final result = attackResult.result;
if (result.isEvaded) {
events.add(
CombatEvent.monsterEvade(
timestamp: timestamp,
targetName: newMonsterStats.name,
),
);
} else {
events.add(
CombatEvent.playerAttack(
timestamp: timestamp,
damage: result.damage,
targetName: newMonsterStats.name,
isCritical: result.isCritical,
attackDelayMs: newPlayerStats.attackDelayMs,
),
if (result.success) {
return (
playerStats: playerStats.copyWith(mpCurrent: result.newMp),
lastPotionUsedMs: timestamp,
potionInventory: result.newInventory!,
events: [
CombatEvent.playerPotion(
timestamp: timestamp,
potionName: mpPotion.name,
healAmount: result.healedAmount,
isHp: false,
),
],
);
}
}
return (
playerStats: newPlayerStats,
monsterStats: newMonsterStats,
skillSystem: newSkillSystem,
activeDoTs: newActiveDoTs,
activeDebuffs: newActiveBuffs,
totalDamageDealt: newTotalDamageDealt,
events: events,
);
return null;
}
/// 몬스터 공격 처리
({
CombatStats playerStats,
int totalDamageTaken,
List<CombatEvent> events,
}) _processMonsterAttack({
({CombatStats playerStats, int totalDamageTaken, List<CombatEvent> events})
_processMonsterAttack({
required CombatStats playerStats,
required MonsterCombatStats monsterStats,
required List<ActiveBuff> activeDebuffs,

View File

@@ -0,0 +1,172 @@
import 'package:asciineverdie/src/core/model/equipment_item.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
/// 플레이어 사망 처리 서비스
///
/// ProgressService에서 분리된 사망 관련 로직 담당:
/// - 장비 손실 계산
/// - 사망 정보 기록
/// - 보스전 레벨링 모드 진입
class DeathHandler {
const DeathHandler();
/// 플레이어 사망 처리 (Phase 4)
///
/// 모든 장비 상실 및 사망 정보 기록.
/// 보스전 사망 시: 장비 보호 + 5분 레벨링 모드 진입.
GameState processPlayerDeath(
GameState state, {
required String killerName,
required DeathCause cause,
}) {
// 사망 직전 전투 이벤트 저장 (최대 10개)
final lastCombatEvents =
state.progress.currentCombat?.recentEvents ?? const [];
// 보스전 사망 여부 확인 (최종 보스 fighting 상태)
final isBossDeath =
state.progress.finalBossState == FinalBossState.fighting;
// 보스전 사망이 아닐 경우에만 장비 손실
var newEquipment = state.equipment;
var lostCount = 0;
String? lostItemName;
EquipmentSlot? lostItemSlot;
ItemRarity? lostItemRarity;
EquipmentItem? lostEquipmentItem; // 광고 부활 시 복구용
if (!isBossDeath) {
final lossResult = _calculateEquipmentLoss(state);
newEquipment = lossResult.equipment;
lostCount = lossResult.lostCount;
lostItemName = lossResult.lostItemName;
lostItemSlot = lossResult.lostItemSlot;
lostItemRarity = lossResult.lostItemRarity;
lostEquipmentItem = lossResult.lostItem;
}
// 사망 정보 생성 (전투 로그 포함)
final deathInfo = DeathInfo(
cause: cause,
killerName: killerName,
lostEquipmentCount: lostCount,
lostItemName: lostItemName,
lostItemSlot: lostItemSlot,
lostItemRarity: lostItemRarity,
lostItem: lostEquipmentItem,
goldAtDeath: state.inventory.gold,
levelAtDeath: state.traits.level,
timestamp: state.skillSystem.elapsedMs,
lastCombatEvents: lastCombatEvents,
);
// 보스전 사망 시 5분 레벨링 모드 진입
final bossLevelingEndTime = isBossDeath
? DateTime.now().millisecondsSinceEpoch +
(5 * 60 * 1000) // 5분
: null;
// 전투 상태 초기화 및 사망 횟수 증가
final progress = state.progress.copyWith(
currentCombat: null,
deathCount: state.progress.deathCount + 1,
bossLevelingEndTime: bossLevelingEndTime,
);
return state.copyWith(
equipment: newEquipment,
progress: progress,
deathInfo: deathInfo,
);
}
/// 장비 손실 계산
({
Equipment equipment,
int lostCount,
String? lostItemName,
EquipmentSlot? lostItemSlot,
ItemRarity? lostItemRarity,
EquipmentItem? lostItem,
})
_calculateEquipmentLoss(GameState state) {
var newEquipment = state.equipment;
// 레벨 기반 장비 손실 확률 계산
// Lv 1: 20%, Lv 5: ~56%, Lv 10+: 100%
// 공식: 20 + (level - 1) * 80 / 9
final level = state.traits.level;
final lossChancePercent = level >= 10
? 100
: (20 + ((level - 1) * 80 ~/ 9)).clamp(0, 100);
final roll = state.rng.nextInt(100); // 0~99
final shouldLoseEquipment = roll < lossChancePercent;
// ignore: avoid_print
print(
'[Death] Lv$level lossChance=$lossChancePercent% roll=$roll '
'shouldLose=$shouldLoseEquipment',
);
if (!shouldLoseEquipment) {
return (
equipment: newEquipment,
lostCount: 0,
lostItemName: null,
lostItemSlot: null,
lostItemRarity: null,
lostItem: null,
);
}
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
final equippedNonWeaponSlots = <int>[];
for (var i = 1; i < Equipment.slotCount; i++) {
final item = state.equipment.getItemByIndex(i);
if (item.isNotEmpty) {
equippedNonWeaponSlots.add(i);
}
}
if (equippedNonWeaponSlots.isEmpty) {
return (
equipment: newEquipment,
lostCount: 0,
lostItemName: null,
lostItemSlot: null,
lostItemRarity: null,
lostItem: null,
);
}
// 랜덤하게 1개 슬롯 선택
final sacrificeIndex =
equippedNonWeaponSlots[state.rng.nextInt(
equippedNonWeaponSlots.length,
)];
// 제물로 바칠 아이템 정보 저장
final lostItem = state.equipment.getItemByIndex(sacrificeIndex);
final lostItemSlot = EquipmentSlot.values[sacrificeIndex];
// 해당 슬롯을 빈 장비로 교체
newEquipment = newEquipment.setItemByIndex(
sacrificeIndex,
EquipmentItem.empty(lostItemSlot),
);
// ignore: avoid_print
print('[Death] Lost item: ${lostItem.name} (slot: $lostItemSlot)');
return (
equipment: newEquipment,
lostCount: 1,
lostItemName: lostItem.name,
lostItemSlot: lostItemSlot,
lostItemRarity: lostItem.rarity,
lostItem: lostItem,
);
}
}

View File

@@ -0,0 +1,151 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart';
/// 디버그 설정 서비스 (Phase 8)
///
/// 개발/테스트 중 사용하는 디버그 옵션을 통합 관리합니다.
/// kDebugMode에서만 활성화됩니다.
class DebugSettingsService {
DebugSettingsService._();
static DebugSettingsService? _instance;
/// 싱글톤 인스턴스
static DebugSettingsService get instance {
_instance ??= DebugSettingsService._();
return _instance!;
}
// ===========================================================================
// 상수
// ===========================================================================
static const String _keyIapSimulated = 'debug_iap_simulated';
static const String _keyOfflineHours = 'debug_offline_hours';
/// 오프라인 시간 시뮬레이션 옵션
static const List<int> offlineHoursOptions = [0, 1, 5, 10, 24];
// ===========================================================================
// 상태
// ===========================================================================
bool _isInitialized = false;
/// IAP 구매 시뮬레이션 여부 (디버그 모드 전용)
bool _iapSimulated = false;
/// 오프라인 시간 시뮬레이션 (시간 단위)
int _offlineHours = 0;
// ===========================================================================
// 초기화
// ===========================================================================
/// 서비스 초기화 (저장된 설정 로드)
Future<void> initialize() async {
if (_isInitialized) return;
if (!kDebugMode) {
_isInitialized = true;
return;
}
final prefs = await SharedPreferences.getInstance();
_iapSimulated = prefs.getBool(_keyIapSimulated) ?? false;
_offlineHours = prefs.getInt(_keyOfflineHours) ?? 0;
// 다른 서비스에 설정 동기화
_syncToServices();
_isInitialized = true;
debugPrint(
'[DebugSettings] Initialized: '
'iap=$_iapSimulated, offline=$_offlineHours',
);
}
/// 설정을 다른 서비스에 동기화
void _syncToServices() {
IAPService.instance.debugIAPSimulated = _iapSimulated;
}
// ===========================================================================
// 디버그 모드 확인
// ===========================================================================
/// 디버그 모드 활성화 여부
bool get isDebugMode => kDebugMode;
// ===========================================================================
// IAP 설정
// ===========================================================================
/// IAP 구매 시뮬레이션 여부
///
/// - true: 유료 유저로 동작 (광고 제거됨)
/// - false: 무료 유저로 동작
bool get iapSimulated => _iapSimulated;
/// IAP 구매 시뮬레이션 토글
Future<void> setIapSimulated(bool value) async {
if (!kDebugMode) return;
_iapSimulated = value;
IAPService.instance.debugIAPSimulated = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_keyIapSimulated, value);
debugPrint('[DebugSettings] IAP simulated: $value');
}
// ===========================================================================
// 오프라인 시간 시뮬레이션
// ===========================================================================
/// 오프라인 시간 시뮬레이션 (시간 단위)
///
/// 복귀 보상 테스트용. 0이면 시뮬레이션 비활성화.
int get offlineHours => _offlineHours;
/// 오프라인 시간 시뮬레이션 설정
Future<void> setOfflineHours(int hours) async {
if (!kDebugMode) return;
_offlineHours = hours;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_keyOfflineHours, hours);
debugPrint('[DebugSettings] Offline hours: $hours');
}
/// 시뮬레이션된 마지막 플레이 시간 계산
///
/// [actualLastPlayTime] 실제 마지막 플레이 시간
/// Returns: 시뮬레이션 적용된 마지막 플레이 시간
DateTime? getSimulatedLastPlayTime(DateTime? actualLastPlayTime) {
if (!kDebugMode || _offlineHours == 0) {
return actualLastPlayTime;
}
// 시뮬레이션: 현재 시간에서 offlineHours만큼 뺀 시간을 마지막 플레이 시간으로
return DateTime.now().subtract(Duration(hours: _offlineHours));
}
// ===========================================================================
// 전체 초기화
// ===========================================================================
/// 모든 디버그 설정 초기화
Future<void> resetAll() async {
if (!kDebugMode) return;
await setIapSimulated(false);
await setOfflineHours(0);
debugPrint('[DebugSettings] All settings reset');
}
}

View File

@@ -0,0 +1,345 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
/// IAP 상품 ID
class IAPProductIds {
IAPProductIds._();
/// 광고 제거 상품 ID (비소모성)
/// TODO: Google Play Console / App Store Connect에서 상품 생성 후 ID 교체
static const String removeAds = 'remove_ads';
/// 모든 상품 ID 목록
static const Set<String> all = {removeAds};
}
/// IAP 구매 결과
enum IAPResult {
/// 구매 성공
success,
/// 구매 취소
cancelled,
/// 구매 실패
failed,
/// 이미 구매됨
alreadyPurchased,
/// 상품을 찾을 수 없음
productNotFound,
/// 스토어 사용 불가
storeUnavailable,
/// 디버그 모드에서 시뮬레이션
debugSimulated,
}
/// IAP 서비스
///
/// 인앱 구매 (광고 제거) 처리를 담당합니다.
/// shared_preferences를 사용하여 구매 상태를 영구 저장합니다.
class IAPService {
IAPService._();
static IAPService? _instance;
/// 싱글톤 인스턴스
static IAPService get instance {
_instance ??= IAPService._();
return _instance!;
}
// ===========================================================================
// 상수
// ===========================================================================
/// 구매 상태 저장 키
static const String _purchaseKey = 'iap_remove_ads_purchased';
// ===========================================================================
// 상태
// ===========================================================================
final InAppPurchase _iap = InAppPurchase.instance;
bool _isInitialized = false;
bool _isAvailable = false;
/// 상품 정보
ProductDetails? _removeAdsProduct;
/// 구매 스트림 구독
StreamSubscription<List<PurchaseDetails>>? _subscription;
/// 광고 제거 구매 여부 (캐시)
bool _adRemovalPurchased = false;
/// 디버그 모드에서 IAP 시뮬레이션 활성화 여부
bool _debugIAPSimulated = false;
// ===========================================================================
// 초기화
// ===========================================================================
/// IAP 서비스 초기화
Future<void> initialize() async {
if (_isInitialized) return;
// 모바일 플랫폼에서만 초기화
if (!Platform.isAndroid && !Platform.isIOS) {
debugPrint('[IAPService] Non-mobile platform, skipping initialization');
return;
}
// 저장된 구매 상태 로드
await _loadPurchaseState();
// 스토어 가용성 확인
_isAvailable = await _iap.isAvailable();
if (!_isAvailable) {
debugPrint('[IAPService] Store not available');
_isInitialized = true;
return;
}
// 구매 스트림 구독
_subscription = _iap.purchaseStream.listen(
_onPurchaseUpdate,
onError: (Object error) {
debugPrint('[IAPService] Purchase stream error: $error');
},
);
// 상품 정보 로드
await _loadProducts();
_isInitialized = true;
debugPrint('[IAPService] Initialized');
}
/// 상품 정보 로드
Future<void> _loadProducts() async {
final response = await _iap.queryProductDetails(IAPProductIds.all);
if (response.notFoundIDs.isNotEmpty) {
debugPrint('[IAPService] Products not found: ${response.notFoundIDs}');
}
for (final product in response.productDetails) {
if (product.id == IAPProductIds.removeAds) {
_removeAdsProduct = product;
debugPrint(
'[IAPService] Product loaded: ${product.id} - ${product.price}',
);
}
}
}
/// 저장된 구매 상태 로드
Future<void> _loadPurchaseState() async {
final prefs = await SharedPreferences.getInstance();
_adRemovalPurchased = prefs.getBool(_purchaseKey) ?? false;
debugPrint('[IAPService] Loaded purchase state: $_adRemovalPurchased');
}
/// 구매 상태 저장
Future<void> _savePurchaseState(bool purchased) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_purchaseKey, purchased);
_adRemovalPurchased = purchased;
debugPrint('[IAPService] Saved purchase state: $purchased');
}
// ===========================================================================
// 디버그 설정
// ===========================================================================
/// 디버그 모드 IAP 시뮬레이션 활성화 여부
bool get debugIAPSimulated => _debugIAPSimulated;
/// 디버그 모드 IAP 시뮬레이션 토글
set debugIAPSimulated(bool value) {
_debugIAPSimulated = value;
if (kDebugMode) {
_adRemovalPurchased = value;
debugPrint('[IAPService] Debug IAP simulated: $value');
}
}
// ===========================================================================
// 구매 상태
// ===========================================================================
/// 광고 제거 구매 여부
bool get isAdRemovalPurchased {
if (kDebugMode && _debugIAPSimulated) return true;
return _adRemovalPurchased;
}
/// 스토어 가용성
bool get isStoreAvailable => _isAvailable;
/// 광고 제거 상품 정보
ProductDetails? get removeAdsProduct => _removeAdsProduct;
/// 광고 제거 상품 가격 문자열
String get removeAdsPrice {
if (_removeAdsProduct != null) {
return _removeAdsProduct!.price;
}
// 스토어 미연결 시 로케일별 대체 가격
return switch (game_l10n.currentGameLocale) {
'ko' => '₩9,900',
'ja' => '¥990',
_ => '\$9.99',
};
}
// ===========================================================================
// 구매
// ===========================================================================
/// 광고 제거 구매
Future<IAPResult> purchaseRemoveAds() async {
// 디버그 모드 시뮬레이션
if (kDebugMode && _debugIAPSimulated) {
debugPrint('[IAPService] Debug: Simulating purchase');
await _savePurchaseState(true);
return IAPResult.debugSimulated;
}
// 이미 구매됨
if (_adRemovalPurchased) {
debugPrint('[IAPService] Already purchased');
return IAPResult.alreadyPurchased;
}
// 스토어 사용 불가
if (!_isAvailable) {
debugPrint('[IAPService] Store not available');
return IAPResult.storeUnavailable;
}
// 상품을 찾을 수 없음
if (_removeAdsProduct == null) {
debugPrint('[IAPService] Product not found');
return IAPResult.productNotFound;
}
// 구매 요청
final purchaseParam = PurchaseParam(productDetails: _removeAdsProduct!);
try {
final success = await _iap.buyNonConsumable(purchaseParam: purchaseParam);
debugPrint('[IAPService] Purchase initiated: $success');
return success ? IAPResult.success : IAPResult.failed;
} catch (e) {
debugPrint('[IAPService] Purchase error: $e');
return IAPResult.failed;
}
}
// ===========================================================================
// 구매 복원
// ===========================================================================
/// 구매 복원
Future<IAPResult> restorePurchases() async {
// 디버그 모드 시뮬레이션
if (kDebugMode && _debugIAPSimulated) {
debugPrint('[IAPService] Debug: Simulating restore');
await _savePurchaseState(true);
return IAPResult.debugSimulated;
}
// 스토어 사용 불가
if (!_isAvailable) {
debugPrint('[IAPService] Store not available');
return IAPResult.storeUnavailable;
}
try {
await _iap.restorePurchases();
debugPrint('[IAPService] Restore initiated');
return IAPResult.success;
} catch (e) {
debugPrint('[IAPService] Restore error: $e');
return IAPResult.failed;
}
}
// ===========================================================================
// 구매 처리
// ===========================================================================
/// 구매 업데이트 처리
void _onPurchaseUpdate(List<PurchaseDetails> purchases) {
for (final purchase in purchases) {
debugPrint(
'[IAPService] Purchase update: ${purchase.productID} - '
'${purchase.status}',
);
switch (purchase.status) {
case PurchaseStatus.pending:
// 구매 대기 중
debugPrint('[IAPService] Purchase pending');
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
// 구매 완료 또는 복원
_handleSuccessfulPurchase(purchase);
case PurchaseStatus.error:
// 구매 실패
debugPrint('[IAPService] Purchase error: ${purchase.error}');
_completePurchase(purchase);
case PurchaseStatus.canceled:
// 구매 취소
debugPrint('[IAPService] Purchase canceled');
_completePurchase(purchase);
}
}
}
/// 구매 성공 처리
Future<void> _handleSuccessfulPurchase(PurchaseDetails purchase) async {
if (purchase.productID == IAPProductIds.removeAds) {
// 구매 검증 (서버 검증 생략, 로컬 저장)
await _savePurchaseState(true);
debugPrint('[IAPService] Ad removal purchased successfully');
}
// 구매 완료 처리
await _completePurchase(purchase);
}
/// 구매 완료 처리 (필수)
Future<void> _completePurchase(PurchaseDetails purchase) async {
if (purchase.pendingCompletePurchase) {
await _iap.completePurchase(purchase);
debugPrint('[IAPService] Purchase completed: ${purchase.productID}');
}
}
// ===========================================================================
// 정리
// ===========================================================================
/// 리소스 해제
void dispose() {
_subscription?.cancel();
_subscription = null;
debugPrint('[IAPService] Disposed');
}
}

View File

@@ -60,23 +60,26 @@ class ItemService {
// 희귀도 결정
// ============================================================================
/// 희귀도 결정 (레벨 기반 확률)
/// 희귀도 결정 (고정 확률)
///
/// 레벨이 높을수록 희귀한 아이템 확률 증가
/// 확률 분포:
/// - Common: 34%
/// - Uncommon: 40%
/// - Rare: 20%
/// - Epic: 5%
/// - Legendary: 1%
ItemRarity determineRarity(int level) {
final roll = rng.nextInt(100);
final legendaryChance = (level * 0.5).clamp(0, 5).toInt(); // 최대 5%
final epicChance = (level * 1.0).clamp(0, 10).toInt(); // 최대 10%
final rareChance = (level * 2.0).clamp(0, 20).toInt(); // 최대 20%
final uncommonChance = (level * 3.0).clamp(0, 30).toInt(); // 최대 30%
if (roll < legendaryChance) return ItemRarity.legendary;
if (roll < legendaryChance + epicChance) return ItemRarity.epic;
if (roll < legendaryChance + epicChance + rareChance)
return ItemRarity.rare;
if (roll < legendaryChance + epicChance + rareChance + uncommonChance) {
return ItemRarity.uncommon;
}
// Legendary: 0-0 (1%)
if (roll < 1) return ItemRarity.legendary;
// Epic: 1-5 (5%)
if (roll < 6) return ItemRarity.epic;
// Rare: 6-25 (20%)
if (roll < 26) return ItemRarity.rare;
// Uncommon: 26-65 (40%)
if (roll < 66) return ItemRarity.uncommon;
// Common: 66-99 (34%)
return ItemRarity.common;
}
@@ -106,6 +109,8 @@ class ItemService {
/// - 느린 무기 (1500ms): atk × 1.4
/// - 기본 무기 (1000ms): atk × 1.0
/// - 빠른 무기 (600ms): atk × 0.7
///
/// 마법 무기 확률: 30% (magAtk 부여)
ItemStats _generateWeaponStats(int baseValue, ItemRarity rarity) {
final criBonus = rarity.index >= ItemRarity.rare.index
? 0.02 + rarity.index * 0.01
@@ -115,39 +120,77 @@ class ItemService {
: 0.0;
// 공속 결정 (600ms ~ 1500ms 범위)
// 희귀도가 높을수록 공속 변동 폭 증가
final speedVariance =
300 + rarity.index * 100; // Common: 300, Legendary: 700
final speedVariance = 300 + rarity.index * 100;
final speedOffset = rng.nextInt(speedVariance * 2) - speedVariance;
final attackSpeed = (1000 + speedOffset).clamp(600, 1500);
// 공속-데미지 역비례 계산
// 기준: 1000ms = 1.0x, 600ms = 0.7x, 1500ms = 1.4x
final speedMultiplier = 0.3 + (attackSpeed / 1000) * 0.7;
final adjustedAtk = (baseValue * speedMultiplier).round();
// 마법 무기 여부 (30% 확률)
final isMagicWeapon = rng.nextInt(100) < 30;
final magAtk = isMagicWeapon ? (adjustedAtk * 0.8).round() : 0;
// 능력치 보너스 (Rare 이상)
final strBonus = rarity.index >= ItemRarity.rare.index && !isMagicWeapon
? rarity.index
: 0;
final intBonus = rarity.index >= ItemRarity.rare.index && isMagicWeapon
? rarity.index
: 0;
return ItemStats(
atk: adjustedAtk,
magAtk: magAtk,
criRate: criBonus,
parryRate: parryBonus,
attackSpeed: attackSpeed,
strBonus: strBonus,
intBonus: intBonus,
);
}
/// 방패 스탯 생성
///
/// DEF 배율 조정 (v2): 방패 DEF를 0.15배로 축소
/// 마법 방어(magDef), CON 보너스 추가
ItemStats _generateShieldStats(int baseValue, ItemRarity rarity) {
final blockBonus = 0.05 + rarity.index * 0.02;
final def = (baseValue * 0.15).round();
return ItemStats(def: def, blockRate: blockBonus);
// 마법 방어 (50% 확률)
final hasMagDef = rng.nextInt(100) < 50;
final magDef = hasMagDef ? (def * 0.7).round() : 0;
// HP 보너스 (Uncommon 이상)
final hpBonus = rarity.index >= ItemRarity.uncommon.index
? baseValue ~/ 3
: 0;
// CON 보너스 (Rare 이상)
final conBonus = rarity.index >= ItemRarity.rare.index ? rarity.index : 0;
return ItemStats(
def: def,
magDef: magDef,
blockRate: blockBonus,
hpBonus: hpBonus,
conBonus: conBonus,
);
}
/// 방어구 스탯 생성
///
/// DEF 배율 조정 (v2): 9개 방어구 합산 DEF ≈ 무기 ATK × 1.6
/// 기존 배율(합계 8.0)에서 대폭 축소하여 일반 공격 데미지 정상화
/// 슬롯별 특화 보너스 추가:
/// - 투구(helm): INT, WIS 보너스
/// - 갑옷(hauberk): HP, CON 보너스
/// - 상완갑/전완갑(brassairts, vambraces): STR, DEX 보너스
/// - 건틀릿(gauntlets): STR, 크리티컬 보너스
/// - 갬비슨(gambeson): HP, MP 보너스
/// - 허벅지갑/정강이갑(cuisses, greaves): CON, 회피 보너스
/// - 철제부츠(sollerets): DEX, 회피 보너스
ItemStats _generateArmorStats(
int baseValue,
ItemRarity rarity,
@@ -155,7 +198,7 @@ class ItemService {
) {
// 슬롯별 방어력 가중치 (총합 ~1.6으로 축소)
final defMultiplier = switch (slot) {
EquipmentSlot.hauberk => 0.30, // 갑옷류 최고
EquipmentSlot.hauberk => 0.30,
EquipmentSlot.helm => 0.25,
EquipmentSlot.gambeson => 0.20,
EquipmentSlot.cuisses => 0.18,
@@ -169,11 +212,87 @@ class ItemService {
final def = (baseValue * defMultiplier).round();
// 희귀도에 따른 추가 보너스
final hpBonus = rarity.index >= ItemRarity.rare.index ? baseValue ~/ 2 : 0;
final evasionBonus = rarity.index >= ItemRarity.epic.index ? 0.01 : 0.0;
// 슬롯별 특화 보너스 계산
return switch (slot) {
// 투구: 지능/지혜 보너스, 마법 방어
EquipmentSlot.helm => ItemStats(
def: def,
magDef: rarity.index >= ItemRarity.uncommon.index ? def ~/ 2 : 0,
hpBonus: rarity.index >= ItemRarity.rare.index ? baseValue ~/ 3 : 0,
intBonus: rarity.index >= ItemRarity.epic.index ? rarity.index : 0,
wisBonus: rarity.index >= ItemRarity.rare.index ? rarity.index - 1 : 0,
),
return ItemStats(def: def, hpBonus: hpBonus, evasion: evasionBonus);
// 갑옷: HP, CON 보너스 (주력 방어구)
EquipmentSlot.hauberk => ItemStats(
def: def,
hpBonus: rarity.index >= ItemRarity.uncommon.index ? baseValue : 0,
conBonus: rarity.index >= ItemRarity.rare.index ? rarity.index : 0,
strBonus: rarity.index >= ItemRarity.epic.index ? rarity.index - 1 : 0,
),
// 상완갑: STR 보너스
EquipmentSlot.brassairts => ItemStats(
def: def,
hpBonus: rarity.index >= ItemRarity.rare.index ? baseValue ~/ 4 : 0,
strBonus: rarity.index >= ItemRarity.uncommon.index ? rarity.index : 0,
),
// 전완갑: DEX 보너스
EquipmentSlot.vambraces => ItemStats(
def: def,
hpBonus: rarity.index >= ItemRarity.rare.index ? baseValue ~/ 4 : 0,
dexBonus: rarity.index >= ItemRarity.uncommon.index ? rarity.index : 0,
),
// 건틀릿: STR, 크리티컬 보너스
EquipmentSlot.gauntlets => ItemStats(
def: def,
criRate: rarity.index >= ItemRarity.rare.index
? 0.01 + rarity.index * 0.005
: 0.0,
strBonus: rarity.index >= ItemRarity.uncommon.index ? rarity.index : 0,
),
// 갬비슨: HP, MP 보너스
EquipmentSlot.gambeson => ItemStats(
def: def,
hpBonus: rarity.index >= ItemRarity.uncommon.index ? baseValue ~/ 2 : 0,
mpBonus: rarity.index >= ItemRarity.uncommon.index ? baseValue ~/ 3 : 0,
wisBonus: rarity.index >= ItemRarity.epic.index ? rarity.index - 1 : 0,
),
// 허벅지갑: CON, 회피 보너스
EquipmentSlot.cuisses => ItemStats(
def: def,
hpBonus: rarity.index >= ItemRarity.rare.index ? baseValue ~/ 3 : 0,
conBonus: rarity.index >= ItemRarity.uncommon.index ? rarity.index : 0,
evasion: rarity.index >= ItemRarity.epic.index ? 0.01 : 0.0,
),
// 정강이갑: CON, 회피 보너스
EquipmentSlot.greaves => ItemStats(
def: def,
hpBonus: rarity.index >= ItemRarity.rare.index ? baseValue ~/ 3 : 0,
conBonus: rarity.index >= ItemRarity.rare.index ? rarity.index - 1 : 0,
evasion: rarity.index >= ItemRarity.epic.index ? 0.01 : 0.0,
),
// 철제부츠: DEX, 회피 보너스
EquipmentSlot.sollerets => ItemStats(
def: def,
dexBonus: rarity.index >= ItemRarity.uncommon.index ? rarity.index : 0,
evasion: rarity.index >= ItemRarity.rare.index
? 0.01 + rarity.index * 0.005
: 0.0,
),
// 기본 (무기, 방패는 여기 안옴)
_ => ItemStats(
def: def,
hpBonus: rarity.index >= ItemRarity.rare.index ? baseValue ~/ 2 : 0,
),
};
}
// ============================================================================

View File

@@ -0,0 +1,80 @@
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
import 'package:asciineverdie/src/core/engine/potion_service.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/model/potion.dart';
/// 전리품 처리 서비스
///
/// ProgressService에서 분리된 전리품 획득 로직 담당:
/// - 몬스터 부위 아이템 인벤토리 추가
/// - 특수 아이템 획득 (WinItem)
/// - 물약 드랍
class LootHandler {
const LootHandler({required this.mutations});
final GameMutations mutations;
/// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630)
({GameState state, Potion? droppedPotion}) winLoot(GameState state) {
final taskInfo = state.progress.currentTask;
final monsterPart = taskInfo.monsterPart ?? '';
final monsterBaseName = taskInfo.monsterBaseName ?? '';
var resultState = state;
// 부위가 '*'이면 WinItem 호출 (특수 아이템)
if (monsterPart == '*') {
resultState = mutations.winItem(resultState);
} else if (monsterPart.isNotEmpty && monsterBaseName.isNotEmpty) {
// 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' +
// ProperCase(Split(fTask.Caption,3))), 1);
// 예: "goblin Claw" 형태로 인벤토리 추가
final itemName =
'${monsterBaseName.toLowerCase()} ${_properCase(monsterPart)}';
// 인벤토리에 추가
final items = [...resultState.inventory.items];
final existing = items.indexWhere((e) => e.name == itemName);
if (existing >= 0) {
items[existing] = items[existing].copyWith(
count: items[existing].count + 1,
);
} else {
items.add(InventoryEntry(name: itemName, count: 1));
}
resultState = resultState.copyWith(
inventory: resultState.inventory.copyWith(items: items),
);
}
// 물약 드랍 시도
final potionService = const PotionService();
final rng = resultState.rng;
final monsterLevel = taskInfo.monsterLevel ?? resultState.traits.level;
final monsterGrade = taskInfo.monsterGrade ?? MonsterGrade.normal;
final (updatedPotionInventory, droppedPotion) = potionService.tryPotionDrop(
playerLevel: resultState.traits.level,
monsterLevel: monsterLevel,
monsterGrade: monsterGrade,
inventory: resultState.potionInventory,
roll: rng.nextInt(100),
typeRoll: rng.nextInt(100),
);
return (
state: resultState.copyWith(
rng: rng,
potionInventory: updatedPotionInventory,
),
droppedPotion: droppedPotion,
);
}
/// 첫 글자만 대문자로 변환 (원본 ProperCase)
String _properCase(String s) {
if (s.isEmpty) return s;
return s[0].toUpperCase() + s.substring(1);
}
}

View File

@@ -9,10 +9,7 @@ import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
/// 판매 처리 결과
class SellResult {
const SellResult({
required this.state,
required this.continuesSelling,
});
const SellResult({required this.state, required this.continuesSelling});
final GameState state;
final bool continuesSelling;

View File

@@ -0,0 +1,411 @@
import 'package:asciineverdie/data/skill_data.dart';
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
import 'package:asciineverdie/src/core/engine/skill_service.dart';
import 'package:asciineverdie/src/core/model/combat_event.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/monster_combat_stats.dart';
import 'package:asciineverdie/src/core/model/skill.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
/// 플레이어 공격 처리 결과
typedef PlayerAttackResult = ({
CombatStats playerStats,
MonsterCombatStats monsterStats,
SkillSystemState skillSystem,
List<DotEffect> activeDoTs,
List<ActiveBuff> activeDebuffs,
int totalDamageDealt,
List<CombatEvent> events,
bool isFirstPlayerAttack,
});
/// 플레이어 공격 처리 서비스
///
/// CombatTickService에서 분리된 플레이어 공격 로직 담당:
/// - 스킬 자동 선택 및 사용
/// - 일반 공격 처리
/// - 첫 공격 보너스
/// - 연속 공격 (Multi-attack)
class PlayerAttackProcessor {
PlayerAttackProcessor({required this.rng});
final DeterministicRandom rng;
/// 플레이어 공격 처리
PlayerAttackResult processAttack({
required GameState state,
required CombatStats playerStats,
required MonsterCombatStats monsterStats,
required SkillSystemState updatedSkillSystem,
required List<DotEffect> activeDoTs,
required List<ActiveBuff> activeDebuffs,
required int totalDamageDealt,
required int timestamp,
required CombatCalculator calculator,
required SkillService skillService,
required bool isFirstPlayerAttack,
required double firstStrikeBonus,
required bool hasMultiAttack,
double healingMultiplier = 1.0,
}) {
final events = <CombatEvent>[];
var newPlayerStats = playerStats;
var newMonsterStats = monsterStats;
var newSkillSystem = updatedSkillSystem;
var newActiveDoTs = [...activeDoTs];
var newActiveBuffs = [...activeDebuffs];
var newTotalDamageDealt = totalDamageDealt;
// 장착된 스킬 슬롯에서 사용 가능한 스킬 ID 목록 조회
var availableSkillIds = state.skillSystem.equippedSkills.allSkills
.map((s) => s.id)
.toList();
// 장착된 스킬이 없으면 기본 스킬 사용
if (availableSkillIds.isEmpty) {
availableSkillIds = SkillData.defaultSkillIds;
}
final selectedSkill = skillService.selectAutoSkill(
player: newPlayerStats,
monster: newMonsterStats,
skillSystem: newSkillSystem,
availableSkillIds: availableSkillIds,
activeDoTs: newActiveDoTs,
activeDebuffs: newActiveBuffs,
);
if (selectedSkill != null && selectedSkill.isAttack) {
final result = _useAttackSkill(
state: state,
skill: selectedSkill,
playerStats: newPlayerStats,
monsterStats: newMonsterStats,
skillSystem: newSkillSystem,
skillService: skillService,
timestamp: timestamp,
);
newPlayerStats = result.playerStats;
newMonsterStats = result.monsterStats;
newTotalDamageDealt += result.damage;
newSkillSystem = result.skillSystem;
events.add(result.event);
} else if (selectedSkill != null && selectedSkill.isDot) {
final result = _useDotSkill(
state: state,
skill: selectedSkill,
playerStats: newPlayerStats,
skillSystem: newSkillSystem,
skillService: skillService,
monsterName: newMonsterStats.name,
timestamp: timestamp,
);
newPlayerStats = result.playerStats;
newSkillSystem = result.skillSystem;
if (result.dotEffect != null) newActiveDoTs.add(result.dotEffect!);
events.add(result.event);
} else if (selectedSkill != null && selectedSkill.isHeal) {
final result = _useHealSkill(
skill: selectedSkill,
playerStats: newPlayerStats,
skillSystem: newSkillSystem,
skillService: skillService,
healingMultiplier: healingMultiplier,
timestamp: timestamp,
);
newPlayerStats = result.playerStats;
newSkillSystem = result.skillSystem;
events.add(result.event);
} else if (selectedSkill != null && selectedSkill.isBuff) {
final result = skillService.useBuffSkill(
skill: selectedSkill,
player: newPlayerStats,
skillSystem: newSkillSystem,
);
newPlayerStats = result.updatedPlayer;
newSkillSystem = result.updatedSkillSystem.startGlobalCooldown();
events.add(
CombatEvent.playerBuff(
timestamp: timestamp,
skillName: selectedSkill.name,
),
);
} else if (selectedSkill != null && selectedSkill.isDebuff) {
final result = _useDebuffSkill(
skill: selectedSkill,
playerStats: newPlayerStats,
skillSystem: newSkillSystem,
skillService: skillService,
activeDebuffs: newActiveBuffs,
monsterName: newMonsterStats.name,
timestamp: timestamp,
);
newPlayerStats = result.playerStats;
newSkillSystem = result.skillSystem;
newActiveBuffs = result.activeDebuffs;
events.add(result.event);
} else {
// 일반 공격
final result = _processNormalAttack(
playerStats: newPlayerStats,
monsterStats: newMonsterStats,
calculator: calculator,
isFirstPlayerAttack: isFirstPlayerAttack,
firstStrikeBonus: firstStrikeBonus,
hasMultiAttack: hasMultiAttack,
timestamp: timestamp,
);
newMonsterStats = result.monsterStats;
newTotalDamageDealt += result.totalDamage;
events.addAll(result.events);
}
return (
playerStats: newPlayerStats,
monsterStats: newMonsterStats,
skillSystem: newSkillSystem,
activeDoTs: newActiveDoTs,
activeDebuffs: newActiveBuffs,
totalDamageDealt: newTotalDamageDealt,
events: events,
isFirstPlayerAttack: false,
);
}
// ============================================================================
// 스킬 사용 헬퍼
// ============================================================================
({
CombatStats playerStats,
MonsterCombatStats monsterStats,
int damage,
SkillSystemState skillSystem,
CombatEvent event,
})
_useAttackSkill({
required GameState state,
required Skill skill,
required CombatStats playerStats,
required MonsterCombatStats monsterStats,
required SkillSystemState skillSystem,
required SkillService skillService,
required int timestamp,
}) {
final skillRank = skillService.getSkillRankFromSkillBook(
state.skillBook,
skill.id,
);
final skillResult = skillService.useAttackSkillWithRank(
skill: skill,
player: playerStats,
monster: monsterStats,
skillSystem: skillSystem,
rank: skillRank,
);
return (
playerStats: skillResult.updatedPlayer,
monsterStats: skillResult.updatedMonster,
damage: skillResult.result.damage,
skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(),
event: CombatEvent.playerSkill(
timestamp: timestamp,
skillName: skill.name,
damage: skillResult.result.damage,
targetName: monsterStats.name,
attackDelayMs: playerStats.attackDelayMs,
),
);
}
({
CombatStats playerStats,
SkillSystemState skillSystem,
DotEffect? dotEffect,
CombatEvent event,
})
_useDotSkill({
required GameState state,
required Skill skill,
required CombatStats playerStats,
required SkillSystemState skillSystem,
required SkillService skillService,
required String monsterName,
required int timestamp,
}) {
final skillResult = skillService.useDotSkill(
skill: skill,
player: playerStats,
skillSystem: skillSystem,
playerInt: state.stats.intelligence,
playerWis: state.stats.wis,
);
return (
playerStats: skillResult.updatedPlayer,
skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(),
dotEffect: skillResult.dotEffect,
event: CombatEvent.playerSkill(
timestamp: timestamp,
skillName: skill.name,
damage: skillResult.result.damage,
targetName: monsterName,
attackDelayMs: playerStats.attackDelayMs,
),
);
}
({CombatStats playerStats, SkillSystemState skillSystem, CombatEvent event})
_useHealSkill({
required Skill skill,
required CombatStats playerStats,
required SkillSystemState skillSystem,
required SkillService skillService,
required double healingMultiplier,
required int timestamp,
}) {
final skillResult = skillService.useHealSkill(
skill: skill,
player: playerStats,
skillSystem: skillSystem,
healingMultiplier: healingMultiplier,
);
return (
playerStats: skillResult.updatedPlayer,
skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(),
event: CombatEvent.playerHeal(
timestamp: timestamp,
healAmount: skillResult.result.healedAmount,
skillName: skill.name,
),
);
}
({
CombatStats playerStats,
SkillSystemState skillSystem,
List<ActiveBuff> activeDebuffs,
CombatEvent event,
})
_useDebuffSkill({
required Skill skill,
required CombatStats playerStats,
required SkillSystemState skillSystem,
required SkillService skillService,
required List<ActiveBuff> activeDebuffs,
required String monsterName,
required int timestamp,
}) {
final skillResult = skillService.useDebuffSkill(
skill: skill,
player: playerStats,
skillSystem: skillSystem,
currentDebuffs: activeDebuffs,
);
var newDebuffs = activeDebuffs;
if (skillResult.debuffEffect != null) {
newDebuffs =
activeDebuffs
.where((d) => d.effect.id != skillResult.debuffEffect!.effect.id)
.toList()
..add(skillResult.debuffEffect!);
}
return (
playerStats: skillResult.updatedPlayer,
skillSystem: skillResult.updatedSkillSystem.startGlobalCooldown(),
activeDebuffs: newDebuffs,
event: CombatEvent.playerDebuff(
timestamp: timestamp,
skillName: skill.name,
targetName: monsterName,
),
);
}
// ============================================================================
// 일반 공격
// ============================================================================
({MonsterCombatStats monsterStats, int totalDamage, List<CombatEvent> events})
_processNormalAttack({
required CombatStats playerStats,
required MonsterCombatStats monsterStats,
required CombatCalculator calculator,
required bool isFirstPlayerAttack,
required double firstStrikeBonus,
required bool hasMultiAttack,
required int timestamp,
}) {
final events = <CombatEvent>[];
var newMonsterStats = monsterStats;
var totalDamage = 0;
final attackResult = calculator.playerAttackMonster(
attacker: playerStats,
defender: newMonsterStats,
);
newMonsterStats = attackResult.updatedDefender;
// 첫 공격 배율 적용
var damage = attackResult.result.damage;
if (isFirstPlayerAttack && firstStrikeBonus > 1.0) {
damage = (damage * firstStrikeBonus).round();
final extraDamage = damage - attackResult.result.damage;
if (extraDamage > 0) {
final newHp = (newMonsterStats.hpCurrent - extraDamage).clamp(
0,
newMonsterStats.hpMax,
);
newMonsterStats = newMonsterStats.copyWith(hpCurrent: newHp);
}
}
totalDamage += damage;
final result = attackResult.result;
if (result.isEvaded) {
events.add(
CombatEvent.monsterEvade(
timestamp: timestamp,
targetName: newMonsterStats.name,
),
);
} else {
events.add(
CombatEvent.playerAttack(
timestamp: timestamp,
damage: damage,
targetName: newMonsterStats.name,
isCritical: result.isCritical,
attackDelayMs: playerStats.attackDelayMs,
),
);
}
// 연속 공격 (Refactor Monk 패시브) - 30% 확률로 추가 공격
if (hasMultiAttack && newMonsterStats.isAlive && rng.nextDouble() < 0.3) {
final extraAttack = calculator.playerAttackMonster(
attacker: playerStats,
defender: newMonsterStats,
);
newMonsterStats = extraAttack.updatedDefender;
totalDamage += extraAttack.result.damage;
if (!extraAttack.result.isEvaded) {
events.add(
CombatEvent.playerAttack(
timestamp: timestamp,
damage: extraAttack.result.damage,
targetName: newMonsterStats.name,
isCritical: extraAttack.result.isCritical,
attackDelayMs: playerStats.attackDelayMs,
),
);
}
}
return (
monsterStats: newMonsterStats,
totalDamage: totalDamage,
events: events,
);
}
}

View File

@@ -10,11 +10,8 @@ import 'package:asciineverdie/src/core/model/potion.dart';
class PotionService {
const PotionService();
/// 긴급 물약 사용 HP 임계치 (30%)
static const double emergencyHpThreshold = 0.30;
/// 긴급 물약 사용 MP 임계치 (20%)
static const double emergencyMpThreshold = 0.20;
/// 글로벌 물약 쿨타임 (1배속 기준 3초)
static const int globalPotionCooldownMs = 3000;
// ============================================================================
// 물약 사용 가능 여부
@@ -40,11 +37,6 @@ class PotionService {
return (false, PotionUseFailReason.outOfStock);
}
// 전투당 종류별 1회 제한 체크
if (!inventory.canUseType(potion.type)) {
return (false, PotionUseFailReason.alreadyUsedThisBattle);
}
return (true, null);
}
@@ -60,6 +52,7 @@ class PotionService {
/// [maxHp] 최대 HP
/// [currentMp] 현재 MP
/// [maxMp] 최대 MP
/// [healingMultiplier] 회복력 배율 (기본 1.0, 클래스 패시브 적용)
PotionUseResult usePotion({
required String potionId,
required PotionInventory inventory,
@@ -67,6 +60,7 @@ class PotionService {
required int maxHp,
required int currentMp,
required int maxMp,
double healingMultiplier = 1.0,
}) {
final (canUse, failReason) = canUsePotion(potionId, inventory);
if (!canUse) {
@@ -79,16 +73,20 @@ class PotionService {
int newMp = currentMp;
if (potion.isHpPotion) {
healedAmount = potion.calculateHeal(maxHp);
// 회복력 보너스 적용 (예: Debugger Paladin +10%)
final baseHeal = potion.calculateHeal(maxHp);
healedAmount = (baseHeal * healingMultiplier).round();
newHp = (currentHp + healedAmount).clamp(0, maxHp);
healedAmount = newHp - currentHp; // 실제 회복량
} else if (potion.isMpPotion) {
healedAmount = potion.calculateHeal(maxMp);
// MP 물약에도 회복력 보너스 적용
final baseHeal = potion.calculateHeal(maxMp);
healedAmount = (baseHeal * healingMultiplier).round();
newMp = (currentMp + healedAmount).clamp(0, maxMp);
healedAmount = newMp - currentMp; // 실제 회복량
}
final newInventory = inventory.usePotion(potionId, potion.type);
final newInventory = inventory.usePotion(potionId);
return PotionUseResult(
success: true,
@@ -106,7 +104,7 @@ class PotionService {
/// 긴급 HP 물약 선택
///
/// HP가 임계치 이하일 때 사용할 최적의 물약 선택
/// 소모된 HP >= 물약 회복량이면 사용할 최적의 물약 선택
/// [currentHp] 현재 HP
/// [maxHp] 최대 HP
/// [inventory] 물약 인벤토리
@@ -117,12 +115,8 @@ class PotionService {
required PotionInventory inventory,
required int playerLevel,
}) {
// 임계치 체크
final hpRatio = currentHp / maxHp;
if (hpRatio > emergencyHpThreshold) return null;
// 전투 중 이미 HP 물약 사용했으면 불가
if (!inventory.canUseType(PotionType.hp)) return null;
final hpLost = maxHp - currentHp;
if (hpLost <= 0) return null;
// 적정 티어 계산
final targetTier = PotionData.tierForLevel(playerLevel);
@@ -131,7 +125,10 @@ class PotionService {
for (var tier = targetTier; tier >= 1; tier--) {
final potion = PotionData.getHpPotionByTier(tier);
if (potion != null && inventory.hasPotion(potion.id)) {
return potion;
final healAmount = potion.calculateHeal(maxHp);
if (hpLost >= healAmount) {
return potion;
}
}
}
@@ -139,7 +136,10 @@ class PotionService {
for (var tier = targetTier + 1; tier <= 5; tier++) {
final potion = PotionData.getHpPotionByTier(tier);
if (potion != null && inventory.hasPotion(potion.id)) {
return potion;
final healAmount = potion.calculateHeal(maxHp);
if (hpLost >= healAmount) {
return potion;
}
}
}
@@ -148,19 +148,15 @@ class PotionService {
/// 긴급 MP 물약 선택
///
/// MP가 임계치 이하일 때 사용할 최적의 물약 선택
/// 소모된 MP >= 물약 회복량이면 사용할 최적의 물약 선택
Potion? selectEmergencyMpPotion({
required int currentMp,
required int maxMp,
required PotionInventory inventory,
required int playerLevel,
}) {
// 임계치 체크
final mpRatio = currentMp / maxMp;
if (mpRatio > emergencyMpThreshold) return null;
// 전투 중 이미 MP 물약 사용했으면 불가
if (!inventory.canUseType(PotionType.mp)) return null;
final mpLost = maxMp - currentMp;
if (mpLost <= 0) return null;
// 적정 티어 계산
final targetTier = PotionData.tierForLevel(playerLevel);
@@ -169,7 +165,10 @@ class PotionService {
for (var tier = targetTier; tier >= 1; tier--) {
final potion = PotionData.getMpPotionByTier(tier);
if (potion != null && inventory.hasPotion(potion.id)) {
return potion;
final healAmount = potion.calculateHeal(maxMp);
if (mpLost >= healAmount) {
return potion;
}
}
}
@@ -177,7 +176,10 @@ class PotionService {
for (var tier = targetTier + 1; tier <= 5; tier++) {
final potion = PotionData.getMpPotionByTier(tier);
if (potion != null && inventory.hasPotion(potion.id)) {
return potion;
final healAmount = potion.calculateHeal(maxMp);
if (mpLost >= healAmount) {
return potion;
}
}
}
@@ -188,11 +190,6 @@ class PotionService {
// 인벤토리 관리
// ============================================================================
/// 전투 종료 시 사용 기록 초기화
PotionInventory resetBattleUsage(PotionInventory inventory) {
return inventory.resetBattleUsage();
}
/// 물약 드랍 추가
PotionInventory addPotionDrop(
PotionInventory inventory,
@@ -470,8 +467,8 @@ enum PotionUseFailReason {
/// 보유 물약 없음 (재고 부족)
outOfStock,
/// 이번 전투에서 이미 해당 종류 물약 사용
alreadyUsedThisBattle,
/// 글로벌 쿨타임 중
onCooldown,
}
/// 물약 구매 결과

View File

@@ -89,6 +89,14 @@ class ProgressLoop {
_speedMultiplier = _availableSpeeds[nextIndex];
}
/// 특정 배속으로 직접 설정
/// 가용 배속 목록에 있는 경우에만 설정
void setSpeed(int speed) {
if (_availableSpeeds.contains(speed)) {
_speedMultiplier = speed;
}
}
/// 가용 배속 목록 업데이트 (명예의 전당 상태 변경 시)
void updateAvailableSpeeds(List<int> speeds) {
if (speeds.isEmpty) return;

File diff suppressed because it is too large Load Diff

View File

@@ -47,6 +47,7 @@ class ResurrectionService {
}
String? lostItemName;
EquipmentSlot? lostItemSlot;
var newEquipment = state.equipment;
if (equippedItems.isNotEmpty) {
@@ -55,12 +56,12 @@ class ResurrectionService {
final slotIndex = equippedItems[random.nextInt(equippedItems.length)];
final lostItem = state.equipment.getItemByIndex(slotIndex);
lostItemName = lostItem.name;
lostItemSlot = EquipmentSlot.values[slotIndex];
// 해당 슬롯만 빈 아이템으로 교체
final slot = EquipmentSlot.values[slotIndex];
newEquipment = state.equipment.setItemByIndex(
slotIndex,
EquipmentItem.empty(slot),
EquipmentItem.empty(lostItemSlot),
);
}
@@ -70,6 +71,7 @@ class ResurrectionService {
killerName: killerName,
lostEquipmentCount: lostItemName != null ? 1 : 0,
lostItemName: lostItemName,
lostItemSlot: lostItemSlot,
goldAtDeath: state.inventory.gold,
levelAtDeath: state.traits.level,
timestamp: state.skillSystem.elapsedMs,
@@ -238,6 +240,132 @@ class ResurrectionService {
return state.inventory.gold >= cost;
}
// ============================================================================
// 광고 부활 (HP 100% + 아이템 복구)
// ============================================================================
/// 광고 부활 처리
///
/// 1. 상실한 아이템 복구 (있는 경우)
/// 2. HP/MP 100% 회복
/// 3. 사망 상태 해제
/// 4. 안전 지역으로 이동 태스크 설정
///
/// Note: 10분 자동부활 버프는 GameSessionController에서 처리
GameState processAdRevive(GameState state) {
if (!state.isDead) return state;
var nextState = state;
// 1. 상실한 아이템 복구 (있는 경우)
if (canRecoverLostItem(state)) {
nextState = processItemRecovery(nextState);
}
// 2. 전체 HP/MP 계산 (장비 + 종족 + 클래스 보너스 포함)
final totalHpMax = _calculateTotalHpMax(nextState);
final totalMpMax = _calculateTotalMpMax(nextState);
// HP/MP 100% 회복 (장비 구매 없이)
nextState = nextState.copyWith(
stats: nextState.stats.copyWith(
hpCurrent: totalHpMax,
mpCurrent: totalMpMax,
),
clearDeathInfo: true, // 사망 상태 해제
);
// 스킬 쿨타임 초기화
nextState = nextState.copyWith(
skillSystem: SkillSystemState.empty().copyWith(
elapsedMs: nextState.skillSystem.elapsedMs,
),
);
// 4. 부활 후 태스크 시퀀스 설정
final resurrectionQueue = <QueueEntry>[
QueueEntry(
kind: QueueKind.task,
durationMillis: 3000,
caption: l10n.taskReturningToTown,
taskType: TaskType.neutral,
),
QueueEntry(
kind: QueueKind.task,
durationMillis: 3000,
caption: l10n.taskRestockingAtShop,
taskType: TaskType.market,
),
QueueEntry(
kind: QueueKind.task,
durationMillis: 2000,
caption: l10n.taskHeadingToHuntingGrounds,
taskType: TaskType.neutral,
),
];
final firstTask = resurrectionQueue.removeAt(0);
nextState = nextState.copyWith(
queue: QueueState(entries: resurrectionQueue),
progress: nextState.progress.copyWith(
currentTask: TaskInfo(
caption: firstTask.caption,
type: firstTask.taskType,
),
task: ProgressBarState(position: 0, max: firstTask.durationMillis),
currentCombat: null,
),
);
return nextState;
}
// ============================================================================
// 아이템 복구 (Phase 5)
// ============================================================================
/// 상실한 아이템 복구 가능 여부 확인
///
/// 사망 상태이고 상실한 아이템이 있을 때만 true
bool canRecoverLostItem(GameState state) {
if (!state.isDead) return false;
if (state.deathInfo == null) return false;
return state.deathInfo!.lostItem != null;
}
/// 상실한 아이템 복구 처리
///
/// 광고 시청 후 호출되며 상실한 아이템을 장비에 복원합니다.
/// Returns: 복구된 상태 (복구 불가 시 원본 상태 반환)
GameState processItemRecovery(GameState state) {
if (!canRecoverLostItem(state)) return state;
final deathInfo = state.deathInfo!;
final lostItem = deathInfo.lostItem!;
final lostSlot = deathInfo.lostItemSlot!;
// 해당 슬롯에 아이템 복원
final slotIndex = lostSlot.index;
final updatedEquipment = state.equipment.setItemByIndex(
slotIndex,
lostItem,
);
// DeathInfo에서 상실 아이템 정보 제거 (복구 완료)
final updatedDeathInfo = deathInfo.copyWith(
lostEquipmentCount: 0,
lostItemName: null,
lostItemSlot: null,
lostItemRarity: null,
lostItem: null,
);
return state.copyWith(
equipment: updatedEquipment,
deathInfo: updatedDeathInfo,
);
}
/// 장비 보존 아이템 적용 (향후 확장용)
///
/// [protectedSlots] 보존할 슬롯 인덱스 목록

View File

@@ -0,0 +1,200 @@
import 'package:flutter/foundation.dart';
import 'package:asciineverdie/src/core/engine/ad_service.dart';
import 'package:asciineverdie/src/core/engine/chest_service.dart';
import 'package:asciineverdie/src/core/engine/iap_service.dart';
import 'package:asciineverdie/src/core/model/treasure_chest.dart';
/// 복귀 보상 서비스 (Phase 7)
///
/// 플레이어가 게임에 복귀했을 때 떠나있던 시간에 따라 보물 상자를 제공합니다.
/// - 최소 복귀 시간: 1시간
/// - 최대 복귀 시간: 24시간 (그 이상은 24시간으로 계산)
/// - 기본 보상: 4시간당 1상자
/// - 보너스 보상: 광고 시청 시 상자 2배
class ReturnRewardsService {
ReturnRewardsService._() : _chestService = ChestService();
static ReturnRewardsService? _instance;
/// 싱글톤 인스턴스
static ReturnRewardsService get instance {
_instance ??= ReturnRewardsService._();
return _instance!;
}
final ChestService _chestService;
// ===========================================================================
// 상수
// ===========================================================================
/// 최소 복귀 시간 (시간)
static const int minHoursAway = 1;
/// 최대 복귀 시간 (시간) - 이 이상은 동일 보상
static const int maxHoursAway = 24;
/// 상자 1개당 필요 시간 (시간)
static const int hoursPerChest = 4;
/// 최대 상자 개수 (무료 유저)
static const int maxChestsFree = 5;
/// 최대 상자 개수 (유료 유저)
static const int maxChestsPaid = 10;
// ===========================================================================
// 보상 계산
// ===========================================================================
/// 복귀 보상 계산
///
/// [lastPlayTime] 마지막 플레이 시각 (null이면 보상 없음)
/// [currentTime] 현재 시각
/// [isPaidUser] 유료 유저 여부 (최대 상자 개수 결정)
/// Returns: 복귀 보상 데이터 (시간이 부족하면 hasReward = false)
ReturnChestReward calculateReward({
required DateTime? lastPlayTime,
required DateTime currentTime,
required bool isPaidUser,
}) {
// 마지막 플레이 시간이 없으면 보상 없음
if (lastPlayTime == null) {
debugPrint('[ReturnRewards] No lastPlayTime, no reward');
return const ReturnChestReward(
hoursAway: 0,
chestCount: 0,
bonusChestCount: 0,
);
}
// 경과 시간 계산
final difference = currentTime.difference(lastPlayTime);
final hoursAway = difference.inHours;
// 최소 시간 미만이면 보상 없음
if (hoursAway < minHoursAway) {
debugPrint('[ReturnRewards] Only $hoursAway hours, need $minHoursAway');
return ReturnChestReward(
hoursAway: hoursAway,
chestCount: 0,
bonusChestCount: 0,
);
}
// 최대 시간 초과 시 최대로 제한
final effectiveHours = hoursAway > maxHoursAway ? maxHoursAway : hoursAway;
// 상자 개수 계산
final maxChests = isPaidUser ? maxChestsPaid : maxChestsFree;
final rawChestCount = effectiveHours ~/ hoursPerChest;
final chestCount = rawChestCount.clamp(0, maxChests);
// 보너스 상자 (광고 시청 시 동일 개수 추가)
final bonusChestCount = chestCount;
debugPrint(
'[ReturnRewards] $hoursAway hours away, '
'chests=$chestCount, bonus=$bonusChestCount, paid=$isPaidUser',
);
return ReturnChestReward(
hoursAway: hoursAway,
chestCount: chestCount,
bonusChestCount: bonusChestCount,
);
}
// ===========================================================================
// 상자 오픈
// ===========================================================================
/// 상자 오픈하여 보상 생성
///
/// [count] 오픈할 상자 개수
/// [playerLevel] 플레이어 레벨 (보상 스케일링용)
List<ChestReward> openChests(int count, int playerLevel) {
return _chestService.openMultipleChests(count, playerLevel);
}
// ===========================================================================
// 보상 수령
// ===========================================================================
/// 기본 보상 수령 (광고 없이)
///
/// [reward] 복귀 보상 데이터
/// [playerLevel] 플레이어 레벨
/// Returns: 오픈된 상자 보상 목록
List<ChestReward> claimBasicReward(
ReturnChestReward reward,
int playerLevel,
) {
if (!reward.hasReward) return [];
debugPrint(
'[ReturnRewards] Basic reward claimed: ${reward.chestCount} chests',
);
return openChests(reward.chestCount, playerLevel);
}
/// 보너스 보상 수령 (광고 시청 후)
///
/// 유료 유저: 무료 보너스
/// 무료 유저: 리워드 광고 시청 후 보너스
/// [reward] 복귀 보상 데이터
/// [playerLevel] 플레이어 레벨
/// Returns: 오픈된 보너스 상자 보상 목록 (광고 실패 시 빈 목록)
Future<List<ChestReward>> claimBonusReward(
ReturnChestReward reward,
int playerLevel,
) async {
if (!reward.hasReward || reward.bonusChestCount <= 0) return [];
// 유료 유저는 무료 보너스
if (IAPService.instance.isAdRemovalPurchased) {
debugPrint(
'[ReturnRewards] Bonus claimed (paid user): '
'${reward.bonusChestCount} chests',
);
return openChests(reward.bonusChestCount, playerLevel);
}
// 무료 유저는 리워드 광고 필요
List<ChestReward> bonusRewards = [];
final adResult = await AdService.instance.showRewardedAd(
adType: AdType.rewardRevive, // 복귀 보상용 리워드 광고
onRewarded: () {
bonusRewards = openChests(reward.bonusChestCount, playerLevel);
},
);
if (adResult == AdResult.completed || adResult == AdResult.debugSkipped) {
debugPrint(
'[ReturnRewards] Bonus claimed (free user with ad): '
'${bonusRewards.length} chests',
);
return bonusRewards;
}
debugPrint('[ReturnRewards] Bonus claim failed: $adResult');
return [];
}
// ===========================================================================
// 시간 포맷팅
// ===========================================================================
/// 복귀 시간을 표시용 문자열로 변환
String formatHoursAway(int hours) {
if (hours < 24) {
return '${hours}h';
}
final days = hours ~/ 24;
final remainingHours = hours % 24;
if (remainingHours == 0) {
return '${days}d';
}
return '${days}d ${remainingHours}h';
}
}

View File

@@ -0,0 +1,200 @@
import 'package:asciineverdie/data/skill_data.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/monster_combat_stats.dart';
import 'package:asciineverdie/src/core/model/skill.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
/// 스킬 자동 선택 AI
///
/// SkillService에서 분리된 전투 중 스킬 자동 선택 로직.
/// 상황별 우선순위에 따라 최적의 스킬을 선택한다.
class SkillAutoSelector {
const SkillAutoSelector({required this.rng});
final DeterministicRandom rng;
/// 전투 중 자동 스킬 선택
///
/// 우선순위:
/// 1. HP < 30% -> 회복 스킬 (최우선)
/// 2. 70% 확률로 일반 공격 (MP 절약, 기본 전투)
/// 3. 30% 확률로 스킬 사용:
/// - 버프: HP > 80% & MP > 60% & 활성 버프 없음
/// - 디버프: 몬스터 HP > 80% & 활성 디버프 없음
/// - DOT: 몬스터 HP > 60% & 활성 DOT 없음
/// - 공격: 보스전이면 강력한 스킬, 일반전이면 효율적 스킬
/// 4. MP < 20% -> 일반 공격
Skill? selectAutoSkill({
required CombatStats player,
required MonsterCombatStats monster,
required SkillSystemState skillSystem,
required List<String> availableSkillIds,
required bool Function(Skill) canUse,
List<DotEffect> activeDoTs = const [],
List<ActiveBuff> activeDebuffs = const [],
}) {
final mpRatio = player.mpRatio;
final hpRatio = player.hpRatio;
// MP 20% 미만이면 일반 공격
if (mpRatio < 0.2) return null;
// 사용 가능한 스킬 필터링
final availableSkills = availableSkillIds
.map((id) => SkillData.getSkillById(id))
.whereType<Skill>()
.where(canUse)
.toList();
if (availableSkills.isEmpty) return null;
// HP < 30% -> 회복 스킬 최우선 (생존)
if (hpRatio < 0.3) {
final healSkill = _findBestHealSkill(availableSkills, player.mpCurrent);
if (healSkill != null) return healSkill;
}
// 70% 확률로 일반 공격 (스킬은 특별한 상황에서만)
final useNormalAttack = rng.nextInt(100) < 70;
if (useNormalAttack) return null;
// === 아래부터 30% 확률로 스킬 사용 ===
// 버프: HP > 80% & MP > 60% (매우 안전할 때만)
if (hpRatio > 0.8 && mpRatio > 0.6) {
final hasActiveBuff = skillSystem.activeBuffs.isNotEmpty;
if (!hasActiveBuff) {
final buffSkill = _findBestBuffSkill(availableSkills, player.mpCurrent);
if (buffSkill != null) return buffSkill;
}
}
// 디버프: 몬스터 HP > 80% & 활성 디버프 없음 (전투 초반)
if (monster.hpRatio > 0.8 && activeDebuffs.isEmpty) {
final debuffSkill = _findBestDebuffSkill(
availableSkills,
player.mpCurrent,
);
if (debuffSkill != null) return debuffSkill;
}
// DOT: 몬스터 HP > 60% & 활성 DOT 없음 (장기전 유리)
if (monster.hpRatio > 0.6 && activeDoTs.isEmpty) {
final dotSkill = _findBestDotSkill(availableSkills, player.mpCurrent);
if (dotSkill != null) return dotSkill;
}
// 보스전 판단 (몬스터 레벨 20 이상 & HP 50% 이상)
final isBossFight = monster.level >= 20 && monster.hpRatio > 0.5;
if (isBossFight) {
return _findStrongestAttackSkill(availableSkills);
}
// 일반 전투 -> MP 효율 좋은 공격 스킬
return _findEfficientAttackSkill(availableSkills);
}
/// 가장 좋은 DOT 스킬 찾기
Skill? _findBestDotSkill(List<Skill> skills, int currentMp) {
final dotSkills = skills
.where((s) => s.isDot && s.mpCost <= currentMp)
.toList();
if (dotSkills.isEmpty) return null;
dotSkills.sort((a, b) {
final aTotal =
(a.baseDotDamage ?? 0) *
((a.baseDotDurationMs ?? 0) ~/ (a.baseDotTickMs ?? 1000));
final bTotal =
(b.baseDotDamage ?? 0) *
((b.baseDotDurationMs ?? 0) ~/ (b.baseDotTickMs ?? 1000));
return bTotal.compareTo(aTotal);
});
return dotSkills.first;
}
/// 가장 좋은 회복 스킬 찾기
Skill? _findBestHealSkill(List<Skill> skills, int currentMp) {
final healSkills = skills
.where((s) => s.isHeal && s.mpCost <= currentMp)
.toList();
if (healSkills.isEmpty) return null;
healSkills.sort((a, b) {
final aValue = a.healPercent * 100 + a.healAmount;
final bValue = b.healPercent * 100 + b.healAmount;
return bValue.compareTo(aValue);
});
return healSkills.first;
}
/// 가장 강력한 공격 스킬 찾기
Skill? _findStrongestAttackSkill(List<Skill> skills) {
final attackSkills = skills.where((s) => s.isAttack).toList();
if (attackSkills.isEmpty) return null;
attackSkills.sort(
(a, b) => b.damageMultiplier.compareTo(a.damageMultiplier),
);
return attackSkills.first;
}
/// MP 효율 좋은 공격 스킬 찾기
Skill? _findEfficientAttackSkill(List<Skill> skills) {
final attackSkills = skills.where((s) => s.isAttack).toList();
if (attackSkills.isEmpty) return null;
attackSkills.sort((a, b) => b.mpEfficiency.compareTo(a.mpEfficiency));
return attackSkills.first;
}
/// 가장 좋은 버프 스킬 찾기
Skill? _findBestBuffSkill(List<Skill> skills, int currentMp) {
final buffSkills = skills
.where((s) => s.isBuff && s.mpCost <= currentMp && s.buff != null)
.toList();
if (buffSkills.isEmpty) return null;
buffSkills.sort((a, b) {
final aValue =
(a.buff?.atkModifier ?? 0) +
(a.buff?.defModifier ?? 0) * 0.5 +
(a.buff?.criRateModifier ?? 0) * 0.3;
final bValue =
(b.buff?.atkModifier ?? 0) +
(b.buff?.defModifier ?? 0) * 0.5 +
(b.buff?.criRateModifier ?? 0) * 0.3;
return bValue.compareTo(aValue);
});
return buffSkills.first;
}
/// 가장 좋은 디버프 스킬 찾기
Skill? _findBestDebuffSkill(List<Skill> skills, int currentMp) {
final debuffSkills = skills
.where((s) => s.isDebuff && s.mpCost <= currentMp && s.buff != null)
.toList();
if (debuffSkills.isEmpty) return null;
debuffSkills.sort((a, b) {
final aValue =
(a.buff?.atkModifier ?? 0).abs() +
(a.buff?.defModifier ?? 0).abs() * 0.5;
final bValue =
(b.buff?.atkModifier ?? 0).abs() +
(b.buff?.defModifier ?? 0).abs() * 0.5;
return bValue.compareTo(aValue);
});
return debuffSkills.first;
}
}

View File

@@ -1,4 +1,5 @@
import 'package:asciineverdie/data/skill_data.dart';
import 'package:asciineverdie/src/core/engine/skill_auto_selector.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/monster_combat_stats.dart';
@@ -63,15 +64,22 @@ class SkillService {
required MonsterCombatStats monster,
required SkillSystemState skillSystem,
}) {
// 데미지 타입에 따른 공격력/방어력 선택
final (attackStat, defenseStat) = _getStatsByDamageType(
skill.damageType,
player,
monster,
);
// 기본 데미지 계산
final baseDamage = player.atk * skill.damageMultiplier;
final baseDamage = attackStat * skill.damageMultiplier;
// 버프 효과 적용
final buffMods = skillSystem.totalBuffModifiers;
final buffedDamage = baseDamage * (1 + buffMods.atkMod);
// 적 방어력 감소 적용
final effectiveMonsterDef = monster.def * (1 - skill.targetDefReduction);
final effectiveMonsterDef = defenseStat * (1 - skill.targetDefReduction);
// 최종 데미지 계산 (방어력 감산 0.3)
final finalDamage = (buffedDamage - effectiveMonsterDef * 0.3)
@@ -105,6 +113,8 @@ class SkillService {
}
/// 회복 스킬 사용
///
/// [healingMultiplier] 회복력 배율 (기본 1.0, 클래스 패시브 적용)
({
SkillUseResult result,
CombatStats updatedPlayer,
@@ -114,6 +124,7 @@ class SkillService {
required Skill skill,
required CombatStats player,
required SkillSystemState skillSystem,
double healingMultiplier = 1.0,
}) {
// 회복량 계산
int healAmount = skill.healAmount;
@@ -121,6 +132,9 @@ class SkillService {
healAmount += (player.hpMax * skill.healPercent).round();
}
// 회복력 보너스 적용 (예: Debugger Paladin +10%, Exception Handler +15%)
healAmount = (healAmount * healingMultiplier).round();
// HP 회복
var updatedPlayer = player.applyHeal(healAmount);
@@ -296,20 +310,12 @@ class SkillService {
}
// ============================================================================
// 자동 스킬 선택
// 자동 스킬 선택 (SkillAutoSelector에 위임)
// ============================================================================
/// 전투 중 자동 스킬 선택
///
/// 우선순위:
/// 1. HP < 30% → 회복 스킬 (최우선)
/// 2. 70% 확률로 일반 공격 (MP 절약, 기본 전투)
/// 3. 30% 확률로 스킬 사용:
/// - 버프: HP > 80% & MP > 60% & 활성 버프 없음
/// - 디버프: 몬스터 HP > 80% & 활성 디버프 없음
/// - DOT: 몬스터 HP > 60% & 활성 DOT 없음
/// - 공격: 보스전이면 강력한 스킬, 일반전이면 효율적 스킬
/// 4. MP < 20% → 일반 공격
/// 세부 로직은 SkillAutoSelector에 위임.
Skill? selectAutoSkill({
required CombatStats player,
required MonsterCombatStats monster,
@@ -318,186 +324,22 @@ class SkillService {
List<DotEffect> activeDoTs = const [],
List<ActiveBuff> activeDebuffs = const [],
}) {
final currentMp = player.mpCurrent;
final mpRatio = player.mpRatio;
final hpRatio = player.hpRatio;
// MP 20% 미만이면 일반 공격
if (mpRatio < 0.2) return null;
// 사용 가능한 스킬 필터링
final availableSkills = availableSkillIds
.map((id) => SkillData.getSkillById(id))
.whereType<Skill>()
.where(
(skill) =>
canUseSkill(
skill: skill,
currentMp: currentMp,
skillSystem: skillSystem,
) ==
null,
)
.toList();
if (availableSkills.isEmpty) return null;
// HP < 30% → 회복 스킬 최우선 (생존)
if (hpRatio < 0.3) {
final healSkill = _findBestHealSkill(availableSkills, currentMp);
if (healSkill != null) return healSkill;
}
// 70% 확률로 일반 공격 (스킬은 특별한 상황에서만)
final useNormalAttack = rng.nextInt(100) < 70;
if (useNormalAttack) return null;
// === 아래부터 30% 확률로 스킬 사용 ===
// 버프: HP > 80% & MP > 60% (매우 안전할 때만)
// 활성 버프가 있으면 건너뜀 (중복 방지)
if (hpRatio > 0.8 && mpRatio > 0.6) {
final hasActiveBuff = skillSystem.activeBuffs.isNotEmpty;
if (!hasActiveBuff) {
final buffSkill = _findBestBuffSkill(availableSkills, currentMp);
if (buffSkill != null) return buffSkill;
}
}
// 디버프: 몬스터 HP > 80% & 활성 디버프 없음 (전투 초반)
if (monster.hpRatio > 0.8 && activeDebuffs.isEmpty) {
final debuffSkill = _findBestDebuffSkill(availableSkills, currentMp);
if (debuffSkill != null) return debuffSkill;
}
// DOT: 몬스터 HP > 60% & 활성 DOT 없음 (장기전 유리)
if (monster.hpRatio > 0.6 && activeDoTs.isEmpty) {
final dotSkill = _findBestDotSkill(availableSkills, currentMp);
if (dotSkill != null) return dotSkill;
}
// 보스전 판단 (몬스터 레벨 20 이상 & HP 50% 이상)
final isBossFight = monster.level >= 20 && monster.hpRatio > 0.5;
if (isBossFight) {
// 가장 강력한 공격 스킬
return _findStrongestAttackSkill(availableSkills);
}
// 일반 전투 → MP 효율 좋은 공격 스킬
return _findEfficientAttackSkill(availableSkills);
}
/// 가장 좋은 DOT 스킬 찾기
///
/// 예상 총 데미지 (틱 × 데미지) 기준으로 선택
Skill? _findBestDotSkill(List<Skill> skills, int currentMp) {
final dotSkills = skills
.where((s) => s.isDot && s.mpCost <= currentMp)
.toList();
if (dotSkills.isEmpty) return null;
// 예상 총 데미지 기준 정렬
dotSkills.sort((a, b) {
final aTotal =
(a.baseDotDamage ?? 0) *
((a.baseDotDurationMs ?? 0) ~/ (a.baseDotTickMs ?? 1000));
final bTotal =
(b.baseDotDamage ?? 0) *
((b.baseDotDurationMs ?? 0) ~/ (b.baseDotTickMs ?? 1000));
return bTotal.compareTo(aTotal);
});
return dotSkills.first;
}
/// 가장 좋은 회복 스킬 찾기
Skill? _findBestHealSkill(List<Skill> skills, int currentMp) {
final healSkills = skills
.where((s) => s.isHeal && s.mpCost <= currentMp)
.toList();
if (healSkills.isEmpty) return null;
// 회복량 기준 정렬 (% 회복 > 고정 회복)
healSkills.sort((a, b) {
final aValue = a.healPercent * 100 + a.healAmount;
final bValue = b.healPercent * 100 + b.healAmount;
return bValue.compareTo(aValue);
});
return healSkills.first;
}
/// 가장 강력한 공격 스킬 찾기
Skill? _findStrongestAttackSkill(List<Skill> skills) {
final attackSkills = skills.where((s) => s.isAttack).toList();
if (attackSkills.isEmpty) return null;
attackSkills.sort(
(a, b) => b.damageMultiplier.compareTo(a.damageMultiplier),
final selector = SkillAutoSelector(rng: rng);
return selector.selectAutoSkill(
player: player,
monster: monster,
skillSystem: skillSystem,
availableSkillIds: availableSkillIds,
canUse: (skill) =>
canUseSkill(
skill: skill,
currentMp: player.mpCurrent,
skillSystem: skillSystem,
) ==
null,
activeDoTs: activeDoTs,
activeDebuffs: activeDebuffs,
);
return attackSkills.first;
}
/// MP 효율 좋은 공격 스킬 찾기
Skill? _findEfficientAttackSkill(List<Skill> skills) {
final attackSkills = skills.where((s) => s.isAttack).toList();
if (attackSkills.isEmpty) return null;
attackSkills.sort((a, b) => b.mpEfficiency.compareTo(a.mpEfficiency));
return attackSkills.first;
}
/// 가장 좋은 버프 스킬 찾기
///
/// ATK 증가 버프 우선, 그 다음 복합 버프
Skill? _findBestBuffSkill(List<Skill> skills, int currentMp) {
final buffSkills = skills
.where((s) => s.isBuff && s.mpCost <= currentMp && s.buff != null)
.toList();
if (buffSkills.isEmpty) return null;
// ATK 증가량 기준 정렬
buffSkills.sort((a, b) {
final aValue =
(a.buff?.atkModifier ?? 0) +
(a.buff?.defModifier ?? 0) * 0.5 +
(a.buff?.criRateModifier ?? 0) * 0.3;
final bValue =
(b.buff?.atkModifier ?? 0) +
(b.buff?.defModifier ?? 0) * 0.5 +
(b.buff?.criRateModifier ?? 0) * 0.3;
return bValue.compareTo(aValue);
});
return buffSkills.first;
}
/// 가장 좋은 디버프 스킬 찾기
///
/// 적 ATK 감소 디버프 우선
Skill? _findBestDebuffSkill(List<Skill> skills, int currentMp) {
final debuffSkills = skills
.where((s) => s.isDebuff && s.mpCost <= currentMp && s.buff != null)
.toList();
if (debuffSkills.isEmpty) return null;
// 디버프 효과 크기 기준 정렬 (음수 값이므로 절대값으로 비교)
debuffSkills.sort((a, b) {
final aValue =
(a.buff?.atkModifier ?? 0).abs() +
(a.buff?.defModifier ?? 0).abs() * 0.5;
final bValue =
(b.buff?.atkModifier ?? 0).abs() +
(b.buff?.defModifier ?? 0).abs() * 0.5;
return bValue.compareTo(aValue);
});
return debuffSkills.first;
}
// ============================================================================
@@ -636,15 +478,22 @@ class SkillService {
// 실제 MP 비용 계산
final actualMpCost = (skill.mpCost * mpMult).round();
// 데미지 타입에 따른 공격력/방어력 선택
final (attackStat, defenseStat) = _getStatsByDamageType(
skill.damageType,
player,
monster,
);
// 기본 데미지 계산 (랭크 배율 적용)
final baseDamage = player.atk * skill.damageMultiplier * rankMult;
final baseDamage = attackStat * skill.damageMultiplier * rankMult;
// 버프 효과 적용
final buffMods = skillSystem.totalBuffModifiers;
final buffedDamage = baseDamage * (1 + buffMods.atkMod);
// 적 방어력 감소 적용
final effectiveMonsterDef = monster.def * (1 - skill.targetDefReduction);
final effectiveMonsterDef = defenseStat * (1 - skill.targetDefReduction);
// 최종 데미지 계산 (방어력 감산 0.3)
final finalDamage = (buffedDamage - effectiveMonsterDef * 0.3)
@@ -708,4 +557,32 @@ class SkillService {
return state.copyWith(skillStates: skillStates);
}
// ============================================================================
// 데미지 타입 헬퍼
// ============================================================================
/// 데미지 타입에 따른 공격력/방어력 스탯 반환
///
/// [damageType] 스킬의 데미지 타입
/// [player] 플레이어 전투 스탯
/// [monster] 몬스터 전투 스탯
/// Returns: (공격력, 방어력) 튜플
(double, double) _getStatsByDamageType(
DamageType damageType,
CombatStats player,
MonsterCombatStats monster,
) {
return switch (damageType) {
DamageType.physical => (player.atk.toDouble(), monster.def.toDouble()),
DamageType.magical => (
player.magAtk.toDouble(),
monster.magDef.toDouble(),
),
DamageType.hybrid => (
(player.atk + player.magAtk) / 2,
(monster.def + monster.magDef) / 2,
),
};
}
}

View File

@@ -187,17 +187,6 @@ class StatCalculator {
return bonus > 0 ? bonus : 1.0;
}
/// 사망 시 보존할 장비 개수 (Coredump Undead 패시브)
///
/// [race] 종족 특성
/// Returns: 보존 장비 개수
int calculateDeathEquipmentPreserve(RaceTraits race) {
if (race.hasPassive(PassiveType.deathEquipmentPreserve)) {
return race.getPassiveValue(PassiveType.deathEquipmentPreserve).round();
}
return 0;
}
/// 연속 공격 가능 여부 (Refactor Monk 패시브)
bool hasMultiAttack(ClassTraits klass) {
return klass.hasPassive(ClassPassiveType.multiAttack);

View File

@@ -0,0 +1,259 @@
import 'dart:math' as math;
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/shared/animation/monster_size.dart';
import 'package:asciineverdie/src/core/engine/act_progression_service.dart';
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
import 'package:asciineverdie/src/core/model/combat_state.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/monster_combat_stats.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/model/pq_config.dart';
import 'package:asciineverdie/src/core/util/balance_constants.dart';
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
/// 태스크 생성 서비스
///
/// ProgressService에서 분리된 다음 태스크 생성 로직 담당:
/// - 시장 이동, 전환 태스크, 보스 리트라이, 몬스터 전투 생성
class TaskGenerator {
const TaskGenerator({required this.config});
final PqConfig config;
/// 큐가 비어있을 때 다음 태스크 생성 (원본 Dequeue 667-684줄)
({ProgressState progress, QueueState queue}) generateNextTask(
GameState state,
) {
var progress = state.progress;
final queue = state.queue;
final oldTaskType = progress.currentTask.type;
// 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,
);
final updatedProgress = taskResult.progress.copyWith(
currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.market),
currentCombat: null,
);
return (progress: updatedProgress, queue: queue);
}
/// 전환 태스크 생성 (buying 또는 heading)
({ProgressState progress, QueueState queue}) _createTransitionTask(
GameState state,
ProgressState progress,
QueueState queue,
) {
final gold = state.inventory.gold;
final equipPrice = state.traits.level * 50;
// Gold 충분 시 장비 구매
if (gold > equipPrice) {
final taskResult = pq_logic.startTask(
progress,
l10n.taskUpgradingHardware(),
5 * 1000,
);
final updatedProgress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.buying,
),
currentCombat: null,
);
return (progress: updatedProgress, queue: queue);
}
// Gold 부족 시 전장 이동
final taskResult = pq_logic.startTask(
progress,
l10n.taskEnteringDebugZone(),
4 * 1000,
);
final updatedProgress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.neutral,
),
currentCombat: null,
);
return (progress: updatedProgress, queue: queue);
}
/// Act Boss 재도전 태스크 생성
({ProgressState progress, QueueState queue}) _createActBossRetryTask(
GameState state,
ProgressState progress,
QueueState queue,
) {
final actProgressionService = ActProgressionService(config: config);
final actBoss = actProgressionService.createActBoss(state);
final combatCalculator = CombatCalculator(rng: state.rng);
final durationMillis = combatCalculator.estimateCombatDurationMs(
player: actBoss.playerStats,
monster: actBoss.monsterStats,
);
final taskResult = pq_logic.startTask(
progress,
l10n.taskDebugging(actBoss.monsterStats.name),
durationMillis,
);
final updatedProgress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.kill,
monsterBaseName: actBoss.monsterStats.name,
monsterPart: '*',
monsterLevel: actBoss.monsterStats.level,
monsterGrade: MonsterGrade.boss,
monsterSize: getBossSizeForAct(state.progress.plotStageCount),
),
currentCombat: actBoss,
);
return (progress: updatedProgress, queue: queue);
}
/// 일반 몬스터 전투 태스크 생성
({ProgressState progress, QueueState queue}) _createMonsterTask(
GameState state,
ProgressState progress,
QueueState queue,
) {
final level = state.traits.level;
// 퀘스트 몬스터 데이터 확인
final questMonster = state.progress.currentQuestMonster;
final questMonsterData = questMonster?.monsterData;
final questLevel = questMonsterData != null
? int.tryParse(questMonsterData.split('|').elementAtOrNull(1) ?? '') ??
0
: null;
// 몬스터 생성
final monsterResult = pq_logic.monsterTask(
config,
state.rng,
level,
questMonsterData,
questLevel,
);
// 몬스터 레벨 조정 (밸런스)
final actMinLevel = ActMonsterLevel.forPlotStage(
state.progress.plotStageCount,
);
final baseLevel = math.max(level, actMinLevel);
final effectiveMonsterLevel = monsterResult.level
.clamp(math.max(1, baseLevel - 3), baseLevel + 3)
.toInt();
// 전투 스탯 생성
final playerCombatStats = CombatStats.fromStats(
stats: state.stats,
equipment: state.equipment,
level: level,
monsterLevel: effectiveMonsterLevel,
);
final monsterCombatStats = MonsterCombatStats.fromLevel(
name: monsterResult.displayName,
level: effectiveMonsterLevel,
speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName),
plotStageCount: state.progress.plotStageCount,
);
// 전투 상태 및 지속시간
final combatState = CombatState.start(
playerStats: playerCombatStats,
monsterStats: monsterCombatStats,
);
final combatCalculator = CombatCalculator(rng: state.rng);
final durationMillis = combatCalculator.estimateCombatDurationMs(
player: playerCombatStats,
monster: monsterCombatStats,
);
final taskResult = pq_logic.startTask(
progress,
l10n.taskDebugging(monsterResult.displayName),
durationMillis,
);
// 몬스터 사이즈 결정
final monsterSize = getMonsterSizeForAct(
plotStageCount: state.progress.plotStageCount,
grade: monsterResult.grade,
rng: state.rng,
);
final updatedProgress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.kill,
monsterBaseName: monsterResult.baseName,
monsterPart: monsterResult.part,
monsterLevel: effectiveMonsterLevel,
monsterGrade: monsterResult.grade,
monsterSize: monsterSize,
),
currentCombat: combatState,
);
return (progress: updatedProgress, queue: queue);
}
}

View File

@@ -1,7 +1,6 @@
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/core/model/combat_stats.dart';
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
import 'package:asciineverdie/src/core/model/potion.dart';
import 'package:asciineverdie/src/core/model/skill.dart';
/// 현재 전투 상태
@@ -20,8 +19,9 @@ class CombatState {
required this.isActive,
this.recentEvents = const [],
this.activeDoTs = const [],
this.usedPotionTypes = const {},
this.lastPotionUsedMs = 0,
this.activeDebuffs = const [],
this.isFirstPlayerAttack = true,
});
/// 플레이어 전투 스탯
@@ -54,12 +54,15 @@ class CombatState {
/// 활성 DOT 효과 목록
final List<DotEffect> activeDoTs;
/// 이번 전투에서 사용한 물약 종류 (종류별 1회 제한)
final Set<PotionType> usedPotionTypes;
/// 마지막 물약 사용 시간 (글로벌 쿨타임용)
final int lastPotionUsedMs;
/// 몬스터에 적용된 활성 디버프 목록
final List<ActiveBuff> activeDebuffs;
/// 첫 번째 플레이어 공격 여부 (firstStrikeBonus 적용용)
final bool isFirstPlayerAttack;
// ============================================================================
// 유틸리티
// ============================================================================
@@ -79,8 +82,10 @@ class CombatState {
/// 몬스터 HP 비율
double get monsterHpRatio => monsterStats.hpRatio;
/// 특정 종류 물약 사용 가능 여부
bool canUsePotionType(PotionType type) => !usedPotionTypes.contains(type);
/// 물약 사용 가능 여부 (글로벌 쿨타임 체크)
bool canUsePotion(int currentMs, int cooldownMs) {
return currentMs - lastPotionUsedMs >= cooldownMs;
}
/// 활성 DOT 존재 여부
bool get hasActiveDoTs => activeDoTs.isNotEmpty;
@@ -121,8 +126,9 @@ class CombatState {
bool? isActive,
List<CombatEvent>? recentEvents,
List<DotEffect>? activeDoTs,
Set<PotionType>? usedPotionTypes,
int? lastPotionUsedMs,
List<ActiveBuff>? activeDebuffs,
bool? isFirstPlayerAttack,
}) {
return CombatState(
playerStats: playerStats ?? this.playerStats,
@@ -137,8 +143,9 @@ class CombatState {
isActive: isActive ?? this.isActive,
recentEvents: recentEvents ?? this.recentEvents,
activeDoTs: activeDoTs ?? this.activeDoTs,
usedPotionTypes: usedPotionTypes ?? this.usedPotionTypes,
lastPotionUsedMs: lastPotionUsedMs ?? this.lastPotionUsedMs,
activeDebuffs: activeDebuffs ?? this.activeDebuffs,
isFirstPlayerAttack: isFirstPlayerAttack ?? this.isFirstPlayerAttack,
);
}

View File

@@ -57,46 +57,64 @@ class CombatStats with _$CombatStats {
// 기본 스탯
/// 힘: 물리 공격력 보정
required int str,
/// 체력: HP, 방어력 보정
required int con,
/// 민첩: 회피율, 크리티컬율, 명중률, 공격 속도
required int dex,
/// 지능: 마법 공격력, MP
required int intelligence,
/// 지혜: 마법 방어력, MP 회복
required int wis,
/// 매력: 상점 가격, 드롭률 보정
required int cha,
// 파생 스탯 (전투용)
/// 물리 공격력
required int atk,
/// 물리 방어력
required int def,
/// 마법 공격력
required int magAtk,
/// 마법 방어력
required int magDef,
/// 크리티컬 확률 (0.0 ~ 1.0)
required double criRate,
/// 크리티컬 데미지 배율 (1.5 ~ 3.0)
required double criDamage,
/// 회피율 (0.0 ~ 0.5)
required double evasion,
/// 명중률 (0.8 ~ 1.0)
required double accuracy,
/// 방패 방어율 (0.0 ~ 0.4)
required double blockRate,
/// 무기로 쳐내기 확률 (0.0 ~ 0.3)
required double parryRate,
/// 공격 딜레이 (밀리초)
required int attackDelayMs,
// 자원
/// 최대 HP
required int hpMax,
/// 현재 HP
required int hpCurrent,
/// 최대 MP
required int mpMax,
/// 현재 MP
required int mpCurrent,
}) = _CombatStats;

View File

@@ -0,0 +1,276 @@
import 'package:asciineverdie/src/core/model/session_statistics.dart';
/// 누적 통계 (Cumulative Statistics)
///
/// GameStatistics에서 분리된 모든 게임 세션의 누적 통계 모델.
class CumulativeStatistics {
const CumulativeStatistics({
required this.totalPlayTimeMs,
required this.totalMonstersKilled,
required this.totalGoldEarned,
required this.totalGoldSpent,
required this.totalSkillsUsed,
required this.totalCriticalHits,
required this.bestCriticalStreak,
required this.totalDamageDealt,
required this.totalDamageTaken,
required this.totalPotionsUsed,
required this.totalItemsSold,
required this.totalQuestsCompleted,
required this.totalDeaths,
required this.totalBossesDefeated,
required this.totalLevelUps,
required this.highestLevel,
required this.highestGoldHeld,
required this.gamesCompleted,
required this.gamesStarted,
});
/// 총 플레이 시간 (밀리초)
final int totalPlayTimeMs;
/// 총 처치한 몬스터 수
final int totalMonstersKilled;
/// 총 획득한 골드
final int totalGoldEarned;
/// 총 소비한 골드
final int totalGoldSpent;
/// 총 스킬 사용 횟수
final int totalSkillsUsed;
/// 총 크리티컬 히트 횟수
final int totalCriticalHits;
/// 최고 연속 크리티컬
final int bestCriticalStreak;
/// 총 입힌 데미지
final int totalDamageDealt;
/// 총 받은 데미지
final int totalDamageTaken;
/// 총 사용한 물약 수
final int totalPotionsUsed;
/// 총 판매한 아이템 수
final int totalItemsSold;
/// 총 완료한 퀘스트 수
final int totalQuestsCompleted;
/// 총 사망 횟수
final int totalDeaths;
/// 총 처치한 보스 수
final int totalBossesDefeated;
/// 총 레벨업 횟수
final int totalLevelUps;
/// 최고 달성 레벨
final int highestLevel;
/// 최대 보유 골드
final int highestGoldHeld;
/// 클리어한 게임 수
final int gamesCompleted;
/// 시작한 게임 수
final int gamesStarted;
/// 총 플레이 시간 Duration
Duration get totalPlayTime => Duration(milliseconds: totalPlayTimeMs);
/// 총 플레이 시간 포맷 (HH:MM:SS)
String get formattedTotalPlayTime {
final hours = totalPlayTime.inHours;
final minutes = totalPlayTime.inMinutes % 60;
final seconds = totalPlayTime.inSeconds % 60;
return '${hours.toString().padLeft(2, '0')}:'
'${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
/// 평균 게임당 플레이 시간
Duration get averagePlayTimePerGame {
if (gamesStarted <= 0) return Duration.zero;
return Duration(milliseconds: totalPlayTimeMs ~/ gamesStarted);
}
/// 게임 완료율
double get completionRate {
if (gamesStarted <= 0) return 0;
return gamesCompleted / gamesStarted;
}
/// 빈 누적 통계
factory CumulativeStatistics.empty() => const CumulativeStatistics(
totalPlayTimeMs: 0,
totalMonstersKilled: 0,
totalGoldEarned: 0,
totalGoldSpent: 0,
totalSkillsUsed: 0,
totalCriticalHits: 0,
bestCriticalStreak: 0,
totalDamageDealt: 0,
totalDamageTaken: 0,
totalPotionsUsed: 0,
totalItemsSold: 0,
totalQuestsCompleted: 0,
totalDeaths: 0,
totalBossesDefeated: 0,
totalLevelUps: 0,
highestLevel: 0,
highestGoldHeld: 0,
gamesCompleted: 0,
gamesStarted: 0,
);
/// 세션 통계 병합
CumulativeStatistics mergeSession(SessionStatistics session) {
return CumulativeStatistics(
totalPlayTimeMs: totalPlayTimeMs + session.playTimeMs,
totalMonstersKilled: totalMonstersKilled + session.monstersKilled,
totalGoldEarned: totalGoldEarned + session.goldEarned,
totalGoldSpent: totalGoldSpent + session.goldSpent,
totalSkillsUsed: totalSkillsUsed + session.skillsUsed,
totalCriticalHits: totalCriticalHits + session.criticalHits,
bestCriticalStreak: session.maxCriticalStreak > bestCriticalStreak
? session.maxCriticalStreak
: bestCriticalStreak,
totalDamageDealt: totalDamageDealt + session.totalDamageDealt,
totalDamageTaken: totalDamageTaken + session.totalDamageTaken,
totalPotionsUsed: totalPotionsUsed + session.potionsUsed,
totalItemsSold: totalItemsSold + session.itemsSold,
totalQuestsCompleted: totalQuestsCompleted + session.questsCompleted,
totalDeaths: totalDeaths + session.deathCount,
totalBossesDefeated: totalBossesDefeated + session.bossesDefeated,
totalLevelUps: totalLevelUps + session.levelUps,
highestLevel: highestLevel,
highestGoldHeld: highestGoldHeld,
gamesCompleted: gamesCompleted,
gamesStarted: gamesStarted,
);
}
/// 최고 레벨 업데이트
CumulativeStatistics updateHighestLevel(int level) {
if (level <= highestLevel) return this;
return copyWith(highestLevel: level);
}
/// 최대 골드 업데이트
CumulativeStatistics updateHighestGold(int gold) {
if (gold <= highestGoldHeld) return this;
return copyWith(highestGoldHeld: gold);
}
/// 새 게임 시작 기록
CumulativeStatistics recordGameStart() {
return copyWith(gamesStarted: gamesStarted + 1);
}
/// 게임 클리어 기록
CumulativeStatistics recordGameComplete() {
return copyWith(gamesCompleted: gamesCompleted + 1);
}
CumulativeStatistics copyWith({
int? totalPlayTimeMs,
int? totalMonstersKilled,
int? totalGoldEarned,
int? totalGoldSpent,
int? totalSkillsUsed,
int? totalCriticalHits,
int? bestCriticalStreak,
int? totalDamageDealt,
int? totalDamageTaken,
int? totalPotionsUsed,
int? totalItemsSold,
int? totalQuestsCompleted,
int? totalDeaths,
int? totalBossesDefeated,
int? totalLevelUps,
int? highestLevel,
int? highestGoldHeld,
int? gamesCompleted,
int? gamesStarted,
}) {
return CumulativeStatistics(
totalPlayTimeMs: totalPlayTimeMs ?? this.totalPlayTimeMs,
totalMonstersKilled: totalMonstersKilled ?? this.totalMonstersKilled,
totalGoldEarned: totalGoldEarned ?? this.totalGoldEarned,
totalGoldSpent: totalGoldSpent ?? this.totalGoldSpent,
totalSkillsUsed: totalSkillsUsed ?? this.totalSkillsUsed,
totalCriticalHits: totalCriticalHits ?? this.totalCriticalHits,
bestCriticalStreak: bestCriticalStreak ?? this.bestCriticalStreak,
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
totalPotionsUsed: totalPotionsUsed ?? this.totalPotionsUsed,
totalItemsSold: totalItemsSold ?? this.totalItemsSold,
totalQuestsCompleted: totalQuestsCompleted ?? this.totalQuestsCompleted,
totalDeaths: totalDeaths ?? this.totalDeaths,
totalBossesDefeated: totalBossesDefeated ?? this.totalBossesDefeated,
totalLevelUps: totalLevelUps ?? this.totalLevelUps,
highestLevel: highestLevel ?? this.highestLevel,
highestGoldHeld: highestGoldHeld ?? this.highestGoldHeld,
gamesCompleted: gamesCompleted ?? this.gamesCompleted,
gamesStarted: gamesStarted ?? this.gamesStarted,
);
}
/// JSON 직렬화
Map<String, dynamic> toJson() {
return {
'totalPlayTimeMs': totalPlayTimeMs,
'totalMonstersKilled': totalMonstersKilled,
'totalGoldEarned': totalGoldEarned,
'totalGoldSpent': totalGoldSpent,
'totalSkillsUsed': totalSkillsUsed,
'totalCriticalHits': totalCriticalHits,
'bestCriticalStreak': bestCriticalStreak,
'totalDamageDealt': totalDamageDealt,
'totalDamageTaken': totalDamageTaken,
'totalPotionsUsed': totalPotionsUsed,
'totalItemsSold': totalItemsSold,
'totalQuestsCompleted': totalQuestsCompleted,
'totalDeaths': totalDeaths,
'totalBossesDefeated': totalBossesDefeated,
'totalLevelUps': totalLevelUps,
'highestLevel': highestLevel,
'highestGoldHeld': highestGoldHeld,
'gamesCompleted': gamesCompleted,
'gamesStarted': gamesStarted,
};
}
/// JSON 역직렬화
factory CumulativeStatistics.fromJson(Map<String, dynamic> json) {
return CumulativeStatistics(
totalPlayTimeMs: json['totalPlayTimeMs'] as int? ?? 0,
totalMonstersKilled: json['totalMonstersKilled'] as int? ?? 0,
totalGoldEarned: json['totalGoldEarned'] as int? ?? 0,
totalGoldSpent: json['totalGoldSpent'] as int? ?? 0,
totalSkillsUsed: json['totalSkillsUsed'] as int? ?? 0,
totalCriticalHits: json['totalCriticalHits'] as int? ?? 0,
bestCriticalStreak: json['bestCriticalStreak'] as int? ?? 0,
totalDamageDealt: json['totalDamageDealt'] as int? ?? 0,
totalDamageTaken: json['totalDamageTaken'] as int? ?? 0,
totalPotionsUsed: json['totalPotionsUsed'] as int? ?? 0,
totalItemsSold: json['totalItemsSold'] as int? ?? 0,
totalQuestsCompleted: json['totalQuestsCompleted'] as int? ?? 0,
totalDeaths: json['totalDeaths'] as int? ?? 0,
totalBossesDefeated: json['totalBossesDefeated'] as int? ?? 0,
totalLevelUps: json['totalLevelUps'] as int? ?? 0,
highestLevel: json['highestLevel'] as int? ?? 0,
highestGoldHeld: json['highestGoldHeld'] as int? ?? 0,
gamesCompleted: json['gamesCompleted'] as int? ?? 0,
gamesStarted: json['gamesStarted'] as int? ?? 0,
);
}
}

View File

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

View File

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

View File

@@ -17,16 +17,23 @@ class EquipmentItem with _$EquipmentItem {
const factory EquipmentItem({
/// 아이템 이름 (예: "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;

View File

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

View File

@@ -1,6 +1,14 @@
import 'package:asciineverdie/src/core/model/cumulative_statistics.dart';
import 'package:asciineverdie/src/core/model/session_statistics.dart';
// 하위 호환성(backward compatibility)을 위한 re-export
export 'package:asciineverdie/src/core/model/cumulative_statistics.dart';
export 'package:asciineverdie/src/core/model/session_statistics.dart';
/// 게임 통계 (Game Statistics)
///
/// 세션 및 누적 통계를 추적하는 모델
/// 세션 및 누적 통계를 추적하는 모델.
/// 세부 구현은 SessionStatistics와 CumulativeStatistics로 분리됨.
class GameStatistics {
const GameStatistics({required this.session, required this.cumulative});
@@ -59,558 +67,3 @@ class GameStatistics {
);
}
}
/// 세션 통계 (Session Statistics)
///
/// 현재 게임 세션의 통계
class SessionStatistics {
const SessionStatistics({
required this.playTimeMs,
required this.monstersKilled,
required this.goldEarned,
required this.goldSpent,
required this.skillsUsed,
required this.criticalHits,
required this.maxCriticalStreak,
required this.currentCriticalStreak,
required this.totalDamageDealt,
required this.totalDamageTaken,
required this.potionsUsed,
required this.itemsSold,
required this.questsCompleted,
required this.deathCount,
required this.bossesDefeated,
required this.levelUps,
});
/// 플레이 시간 (밀리초)
final int playTimeMs;
/// 처치한 몬스터 수
final int monstersKilled;
/// 획득한 골드 총량
final int goldEarned;
/// 소비한 골드 총량
final int goldSpent;
/// 사용한 스킬 횟수
final int skillsUsed;
/// 크리티컬 히트 횟수
final int criticalHits;
/// 최대 연속 크리티컬
final int maxCriticalStreak;
/// 현재 연속 크리티컬 (내부 추적용)
final int currentCriticalStreak;
/// 총 입힌 데미지
final int totalDamageDealt;
/// 총 받은 데미지
final int totalDamageTaken;
/// 사용한 물약 수
final int potionsUsed;
/// 판매한 아이템 수
final int itemsSold;
/// 완료한 퀘스트 수
final int questsCompleted;
/// 사망 횟수
final int deathCount;
/// 처치한 보스 수
final int bossesDefeated;
/// 레벨업 횟수
final int levelUps;
/// 플레이 시간 Duration
Duration get playTime => Duration(milliseconds: playTimeMs);
/// 플레이 시간 포맷 (HH:MM:SS)
String get formattedPlayTime {
final hours = playTime.inHours;
final minutes = playTime.inMinutes % 60;
final seconds = playTime.inSeconds % 60;
return '${hours.toString().padLeft(2, '0')}:'
'${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
/// 평균 DPS (damage per second)
double get averageDps {
if (playTimeMs <= 0) return 0;
return totalDamageDealt / (playTimeMs / 1000);
}
/// 킬당 평균 골드
double get goldPerKill {
if (monstersKilled <= 0) return 0;
return goldEarned / monstersKilled;
}
/// 크리티컬 비율
double get criticalRate {
if (skillsUsed <= 0) return 0;
return criticalHits / skillsUsed;
}
/// 빈 세션 통계
factory SessionStatistics.empty() => const SessionStatistics(
playTimeMs: 0,
monstersKilled: 0,
goldEarned: 0,
goldSpent: 0,
skillsUsed: 0,
criticalHits: 0,
maxCriticalStreak: 0,
currentCriticalStreak: 0,
totalDamageDealt: 0,
totalDamageTaken: 0,
potionsUsed: 0,
itemsSold: 0,
questsCompleted: 0,
deathCount: 0,
bossesDefeated: 0,
levelUps: 0,
);
// ============================================================================
// 이벤트 기록 메서드
// ============================================================================
/// 몬스터 처치 기록
SessionStatistics recordKill({bool isBoss = false}) {
return copyWith(
monstersKilled: monstersKilled + 1,
bossesDefeated: isBoss ? bossesDefeated + 1 : bossesDefeated,
);
}
/// 골드 획득 기록
SessionStatistics recordGoldEarned(int amount) {
return copyWith(goldEarned: goldEarned + amount);
}
/// 골드 소비 기록
SessionStatistics recordGoldSpent(int amount) {
return copyWith(goldSpent: goldSpent + amount);
}
/// 스킬 사용 기록
SessionStatistics recordSkillUse({required bool isCritical}) {
final newCriticalStreak = isCritical ? currentCriticalStreak + 1 : 0;
final newMaxStreak = newCriticalStreak > maxCriticalStreak
? newCriticalStreak
: maxCriticalStreak;
return copyWith(
skillsUsed: skillsUsed + 1,
criticalHits: isCritical ? criticalHits + 1 : criticalHits,
currentCriticalStreak: newCriticalStreak,
maxCriticalStreak: newMaxStreak,
);
}
/// 데미지 기록
SessionStatistics recordDamage({int dealt = 0, int taken = 0}) {
return copyWith(
totalDamageDealt: totalDamageDealt + dealt,
totalDamageTaken: totalDamageTaken + taken,
);
}
/// 물약 사용 기록
SessionStatistics recordPotionUse() {
return copyWith(potionsUsed: potionsUsed + 1);
}
/// 아이템 판매 기록
SessionStatistics recordItemSold(int count) {
return copyWith(itemsSold: itemsSold + count);
}
/// 퀘스트 완료 기록
SessionStatistics recordQuestComplete() {
return copyWith(questsCompleted: questsCompleted + 1);
}
/// 사망 기록
SessionStatistics recordDeath() {
return copyWith(deathCount: deathCount + 1);
}
/// 레벨업 기록
SessionStatistics recordLevelUp() {
return copyWith(levelUps: levelUps + 1);
}
/// 플레이 시간 업데이트
SessionStatistics updatePlayTime(int elapsedMs) {
return copyWith(playTimeMs: elapsedMs);
}
SessionStatistics copyWith({
int? playTimeMs,
int? monstersKilled,
int? goldEarned,
int? goldSpent,
int? skillsUsed,
int? criticalHits,
int? maxCriticalStreak,
int? currentCriticalStreak,
int? totalDamageDealt,
int? totalDamageTaken,
int? potionsUsed,
int? itemsSold,
int? questsCompleted,
int? deathCount,
int? bossesDefeated,
int? levelUps,
}) {
return SessionStatistics(
playTimeMs: playTimeMs ?? this.playTimeMs,
monstersKilled: monstersKilled ?? this.monstersKilled,
goldEarned: goldEarned ?? this.goldEarned,
goldSpent: goldSpent ?? this.goldSpent,
skillsUsed: skillsUsed ?? this.skillsUsed,
criticalHits: criticalHits ?? this.criticalHits,
maxCriticalStreak: maxCriticalStreak ?? this.maxCriticalStreak,
currentCriticalStreak:
currentCriticalStreak ?? this.currentCriticalStreak,
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
potionsUsed: potionsUsed ?? this.potionsUsed,
itemsSold: itemsSold ?? this.itemsSold,
questsCompleted: questsCompleted ?? this.questsCompleted,
deathCount: deathCount ?? this.deathCount,
bossesDefeated: bossesDefeated ?? this.bossesDefeated,
levelUps: levelUps ?? this.levelUps,
);
}
/// JSON 직렬화
Map<String, dynamic> toJson() {
return {
'playTimeMs': playTimeMs,
'monstersKilled': monstersKilled,
'goldEarned': goldEarned,
'goldSpent': goldSpent,
'skillsUsed': skillsUsed,
'criticalHits': criticalHits,
'maxCriticalStreak': maxCriticalStreak,
'totalDamageDealt': totalDamageDealt,
'totalDamageTaken': totalDamageTaken,
'potionsUsed': potionsUsed,
'itemsSold': itemsSold,
'questsCompleted': questsCompleted,
'deathCount': deathCount,
'bossesDefeated': bossesDefeated,
'levelUps': levelUps,
};
}
/// JSON 역직렬화
factory SessionStatistics.fromJson(Map<String, dynamic> json) {
return SessionStatistics(
playTimeMs: json['playTimeMs'] as int? ?? 0,
monstersKilled: json['monstersKilled'] as int? ?? 0,
goldEarned: json['goldEarned'] as int? ?? 0,
goldSpent: json['goldSpent'] as int? ?? 0,
skillsUsed: json['skillsUsed'] as int? ?? 0,
criticalHits: json['criticalHits'] as int? ?? 0,
maxCriticalStreak: json['maxCriticalStreak'] as int? ?? 0,
currentCriticalStreak: 0, // 세션간 유지 안 함
totalDamageDealt: json['totalDamageDealt'] as int? ?? 0,
totalDamageTaken: json['totalDamageTaken'] as int? ?? 0,
potionsUsed: json['potionsUsed'] as int? ?? 0,
itemsSold: json['itemsSold'] as int? ?? 0,
questsCompleted: json['questsCompleted'] as int? ?? 0,
deathCount: json['deathCount'] as int? ?? 0,
bossesDefeated: json['bossesDefeated'] as int? ?? 0,
levelUps: json['levelUps'] as int? ?? 0,
);
}
}
/// 누적 통계 (Cumulative Statistics)
///
/// 모든 게임 세션의 누적 통계
class CumulativeStatistics {
const CumulativeStatistics({
required this.totalPlayTimeMs,
required this.totalMonstersKilled,
required this.totalGoldEarned,
required this.totalGoldSpent,
required this.totalSkillsUsed,
required this.totalCriticalHits,
required this.bestCriticalStreak,
required this.totalDamageDealt,
required this.totalDamageTaken,
required this.totalPotionsUsed,
required this.totalItemsSold,
required this.totalQuestsCompleted,
required this.totalDeaths,
required this.totalBossesDefeated,
required this.totalLevelUps,
required this.highestLevel,
required this.highestGoldHeld,
required this.gamesCompleted,
required this.gamesStarted,
});
/// 총 플레이 시간 (밀리초)
final int totalPlayTimeMs;
/// 총 처치한 몬스터 수
final int totalMonstersKilled;
/// 총 획득한 골드
final int totalGoldEarned;
/// 총 소비한 골드
final int totalGoldSpent;
/// 총 스킬 사용 횟수
final int totalSkillsUsed;
/// 총 크리티컬 히트 횟수
final int totalCriticalHits;
/// 최고 연속 크리티컬
final int bestCriticalStreak;
/// 총 입힌 데미지
final int totalDamageDealt;
/// 총 받은 데미지
final int totalDamageTaken;
/// 총 사용한 물약 수
final int totalPotionsUsed;
/// 총 판매한 아이템 수
final int totalItemsSold;
/// 총 완료한 퀘스트 수
final int totalQuestsCompleted;
/// 총 사망 횟수
final int totalDeaths;
/// 총 처치한 보스 수
final int totalBossesDefeated;
/// 총 레벨업 횟수
final int totalLevelUps;
/// 최고 달성 레벨
final int highestLevel;
/// 최대 보유 골드
final int highestGoldHeld;
/// 클리어한 게임 수
final int gamesCompleted;
/// 시작한 게임 수
final int gamesStarted;
/// 총 플레이 시간 Duration
Duration get totalPlayTime => Duration(milliseconds: totalPlayTimeMs);
/// 총 플레이 시간 포맷 (HH:MM:SS)
String get formattedTotalPlayTime {
final hours = totalPlayTime.inHours;
final minutes = totalPlayTime.inMinutes % 60;
final seconds = totalPlayTime.inSeconds % 60;
return '${hours.toString().padLeft(2, '0')}:'
'${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
/// 평균 게임당 플레이 시간
Duration get averagePlayTimePerGame {
if (gamesStarted <= 0) return Duration.zero;
return Duration(milliseconds: totalPlayTimeMs ~/ gamesStarted);
}
/// 게임 완료율
double get completionRate {
if (gamesStarted <= 0) return 0;
return gamesCompleted / gamesStarted;
}
/// 빈 누적 통계
factory CumulativeStatistics.empty() => const CumulativeStatistics(
totalPlayTimeMs: 0,
totalMonstersKilled: 0,
totalGoldEarned: 0,
totalGoldSpent: 0,
totalSkillsUsed: 0,
totalCriticalHits: 0,
bestCriticalStreak: 0,
totalDamageDealt: 0,
totalDamageTaken: 0,
totalPotionsUsed: 0,
totalItemsSold: 0,
totalQuestsCompleted: 0,
totalDeaths: 0,
totalBossesDefeated: 0,
totalLevelUps: 0,
highestLevel: 0,
highestGoldHeld: 0,
gamesCompleted: 0,
gamesStarted: 0,
);
/// 세션 통계 병합
CumulativeStatistics mergeSession(SessionStatistics session) {
return CumulativeStatistics(
totalPlayTimeMs: totalPlayTimeMs + session.playTimeMs,
totalMonstersKilled: totalMonstersKilled + session.monstersKilled,
totalGoldEarned: totalGoldEarned + session.goldEarned,
totalGoldSpent: totalGoldSpent + session.goldSpent,
totalSkillsUsed: totalSkillsUsed + session.skillsUsed,
totalCriticalHits: totalCriticalHits + session.criticalHits,
bestCriticalStreak: session.maxCriticalStreak > bestCriticalStreak
? session.maxCriticalStreak
: bestCriticalStreak,
totalDamageDealt: totalDamageDealt + session.totalDamageDealt,
totalDamageTaken: totalDamageTaken + session.totalDamageTaken,
totalPotionsUsed: totalPotionsUsed + session.potionsUsed,
totalItemsSold: totalItemsSold + session.itemsSold,
totalQuestsCompleted: totalQuestsCompleted + session.questsCompleted,
totalDeaths: totalDeaths + session.deathCount,
totalBossesDefeated: totalBossesDefeated + session.bossesDefeated,
totalLevelUps: totalLevelUps + session.levelUps,
highestLevel: highestLevel, // 별도 업데이트 필요
highestGoldHeld: highestGoldHeld, // 별도 업데이트 필요
gamesCompleted: gamesCompleted, // 별도 업데이트 필요
gamesStarted: gamesStarted, // 별도 업데이트 필요
);
}
/// 최고 레벨 업데이트
CumulativeStatistics updateHighestLevel(int level) {
if (level <= highestLevel) return this;
return copyWith(highestLevel: level);
}
/// 최대 골드 업데이트
CumulativeStatistics updateHighestGold(int gold) {
if (gold <= highestGoldHeld) return this;
return copyWith(highestGoldHeld: gold);
}
/// 새 게임 시작 기록
CumulativeStatistics recordGameStart() {
return copyWith(gamesStarted: gamesStarted + 1);
}
/// 게임 클리어 기록
CumulativeStatistics recordGameComplete() {
return copyWith(gamesCompleted: gamesCompleted + 1);
}
CumulativeStatistics copyWith({
int? totalPlayTimeMs,
int? totalMonstersKilled,
int? totalGoldEarned,
int? totalGoldSpent,
int? totalSkillsUsed,
int? totalCriticalHits,
int? bestCriticalStreak,
int? totalDamageDealt,
int? totalDamageTaken,
int? totalPotionsUsed,
int? totalItemsSold,
int? totalQuestsCompleted,
int? totalDeaths,
int? totalBossesDefeated,
int? totalLevelUps,
int? highestLevel,
int? highestGoldHeld,
int? gamesCompleted,
int? gamesStarted,
}) {
return CumulativeStatistics(
totalPlayTimeMs: totalPlayTimeMs ?? this.totalPlayTimeMs,
totalMonstersKilled: totalMonstersKilled ?? this.totalMonstersKilled,
totalGoldEarned: totalGoldEarned ?? this.totalGoldEarned,
totalGoldSpent: totalGoldSpent ?? this.totalGoldSpent,
totalSkillsUsed: totalSkillsUsed ?? this.totalSkillsUsed,
totalCriticalHits: totalCriticalHits ?? this.totalCriticalHits,
bestCriticalStreak: bestCriticalStreak ?? this.bestCriticalStreak,
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
totalPotionsUsed: totalPotionsUsed ?? this.totalPotionsUsed,
totalItemsSold: totalItemsSold ?? this.totalItemsSold,
totalQuestsCompleted: totalQuestsCompleted ?? this.totalQuestsCompleted,
totalDeaths: totalDeaths ?? this.totalDeaths,
totalBossesDefeated: totalBossesDefeated ?? this.totalBossesDefeated,
totalLevelUps: totalLevelUps ?? this.totalLevelUps,
highestLevel: highestLevel ?? this.highestLevel,
highestGoldHeld: highestGoldHeld ?? this.highestGoldHeld,
gamesCompleted: gamesCompleted ?? this.gamesCompleted,
gamesStarted: gamesStarted ?? this.gamesStarted,
);
}
/// JSON 직렬화
Map<String, dynamic> toJson() {
return {
'totalPlayTimeMs': totalPlayTimeMs,
'totalMonstersKilled': totalMonstersKilled,
'totalGoldEarned': totalGoldEarned,
'totalGoldSpent': totalGoldSpent,
'totalSkillsUsed': totalSkillsUsed,
'totalCriticalHits': totalCriticalHits,
'bestCriticalStreak': bestCriticalStreak,
'totalDamageDealt': totalDamageDealt,
'totalDamageTaken': totalDamageTaken,
'totalPotionsUsed': totalPotionsUsed,
'totalItemsSold': totalItemsSold,
'totalQuestsCompleted': totalQuestsCompleted,
'totalDeaths': totalDeaths,
'totalBossesDefeated': totalBossesDefeated,
'totalLevelUps': totalLevelUps,
'highestLevel': highestLevel,
'highestGoldHeld': highestGoldHeld,
'gamesCompleted': gamesCompleted,
'gamesStarted': gamesStarted,
};
}
/// JSON 역직렬화
factory CumulativeStatistics.fromJson(Map<String, dynamic> json) {
return CumulativeStatistics(
totalPlayTimeMs: json['totalPlayTimeMs'] as int? ?? 0,
totalMonstersKilled: json['totalMonstersKilled'] as int? ?? 0,
totalGoldEarned: json['totalGoldEarned'] as int? ?? 0,
totalGoldSpent: json['totalGoldSpent'] as int? ?? 0,
totalSkillsUsed: json['totalSkillsUsed'] as int? ?? 0,
totalCriticalHits: json['totalCriticalHits'] as int? ?? 0,
bestCriticalStreak: json['bestCriticalStreak'] as int? ?? 0,
totalDamageDealt: json['totalDamageDealt'] as int? ?? 0,
totalDamageTaken: json['totalDamageTaken'] as int? ?? 0,
totalPotionsUsed: json['totalPotionsUsed'] as int? ?? 0,
totalItemsSold: json['totalItemsSold'] as int? ?? 0,
totalQuestsCompleted: json['totalQuestsCompleted'] as int? ?? 0,
totalDeaths: json['totalDeaths'] as int? ?? 0,
totalBossesDefeated: json['totalBossesDefeated'] as int? ?? 0,
totalLevelUps: json['totalLevelUps'] as int? ?? 0,
highestLevel: json['highestLevel'] as int? ?? 0,
highestGoldHeld: json['highestGoldHeld'] as int? ?? 0,
gamesCompleted: json['gamesCompleted'] as int? ?? 0,
gamesStarted: json['gamesStarted'] as int? ?? 0,
);
}
}

View File

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

View File

@@ -41,36 +41,52 @@ class ItemStats with _$ItemStats {
const factory ItemStats({
/// 물리 공격력 보정
@Default(0) int atk,
/// 물리 방어력 보정
@Default(0) int def,
/// 마법 공격력 보정
@Default(0) int magAtk,
/// 마법 방어력 보정
@Default(0) int magDef,
/// 크리티컬 확률 보정 (0.0 ~ 1.0)
@Default(0.0) double criRate,
/// 회피율 보정 (0.0 ~ 1.0)
@Default(0.0) double evasion,
/// 방패 방어율 (방패 전용, 0.0 ~ 1.0)
@Default(0.0) double blockRate,
/// 무기 쳐내기 확률 (무기 전용, 0.0 ~ 1.0)
@Default(0.0) double parryRate,
/// HP 보너스
@Default(0) int hpBonus,
/// MP 보너스
@Default(0) int mpBonus,
/// STR 보너스
@Default(0) int strBonus,
/// CON 보너스
@Default(0) int conBonus,
/// DEX 보너스
@Default(0) int dexBonus,
/// INT 보너스
@Default(0) int intBonus,
/// WIS 보너스
@Default(0) int wisBonus,
/// CHA 보너스
@Default(0) int chaBonus,
/// 무기 공격속도 (밀리초, 무기 전용)
///
/// 0이면 기본값(1000ms) 사용, 값이 클수록 느린 공격.

View File

@@ -0,0 +1,166 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
part 'monetization_state.freezed.dart';
part 'monetization_state.g.dart';
/// 수익화 시스템 상태
///
/// IAP 구매 여부, 광고 관련 버프, 복귀 보상 등을 관리
@freezed
class MonetizationState with _$MonetizationState {
const MonetizationState._();
const factory MonetizationState({
/// IAP 광고 제거 구매 여부
@Default(false) bool adRemovalPurchased,
/// 캐릭터 생성 굴리기 남은 횟수 (0-5)
@Default(5) int rollsRemaining,
/// 되돌리기 남은 횟수
@Default(1) int undoRemaining,
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
// ignore: invalid_annotation_target
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
List<Stats>? rollHistory,
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
int? autoReviveEndMs,
/// 5배속 버프 종료 시점 (elapsedMs 기준)
int? speedBoostEndMs,
/// 마지막 플레이 시각 (복귀 보상 계산용)
// ignore: invalid_annotation_target
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
DateTime? lastPlayTime,
/// 미개봉 보물 상자 개수
@Default(0) int pendingChests,
/// 행운의 부적 버프 종료 시점 (elapsedMs 기준)
int? luckyCharmEndMs,
}) = _MonetizationState;
factory MonetizationState.fromJson(Map<String, dynamic> json) =>
_$MonetizationStateFromJson(json);
/// 기본 상태 생성 (신규 게임)
factory MonetizationState.initial({bool isPaidUser = false}) {
return MonetizationState(
adRemovalPurchased: isPaidUser,
rollsRemaining: 5,
undoRemaining: isPaidUser ? 3 : 1,
rollHistory: null,
pendingChests: 0,
);
}
// ===========================================================================
// 유틸리티 메서드
// ===========================================================================
/// 유료 사용자 여부
bool get isPaidUser => adRemovalPurchased;
/// 무료 사용자 여부
bool get isFreeUser => !adRemovalPurchased;
/// 자동부활 버프 활성 여부 (elapsedMs 기준)
bool isAutoReviveActive(int elapsedMs) {
if (autoReviveEndMs == null) return false;
return elapsedMs < autoReviveEndMs!;
}
/// 5배속 버프 활성 여부 (elapsedMs 기준)
/// 유료 사용자는 항상 활성
bool isSpeedBoostActive(int elapsedMs) {
if (isPaidUser) return true;
if (speedBoostEndMs == null) return false;
return elapsedMs < speedBoostEndMs!;
}
/// 행운의 부적 버프 활성 여부 (elapsedMs 기준)
bool isLuckyCharmActive(int elapsedMs) {
if (luckyCharmEndMs == null) return false;
return elapsedMs < luckyCharmEndMs!;
}
/// 굴리기 가능 여부
bool get canRoll => rollsRemaining > 0;
/// 되돌리기 가능 여부
bool canUndo(int historyLength) {
if (undoRemaining <= 0) return false;
if (rollHistory == null || rollHistory!.isEmpty) return false;
return historyLength > 0;
}
/// 실제 사용 가능한 되돌리기 횟수
int availableUndos(int historyLength) {
if (rollHistory == null) return 0;
final historyAvailable = rollHistory!.length;
return undoRemaining < historyAvailable ? undoRemaining : historyAvailable;
}
/// 최대 보물 상자 개수
int get maxChests => isPaidUser ? 10 : 5;
/// 보물 상자가 가득 찼는지 여부
bool get isChestsFull => pendingChests >= maxChests;
}
// =============================================================================
// JSON 변환 헬퍼
// =============================================================================
/// Stats 리스트 → JSON
List<Map<String, dynamic>>? _statsListToJson(List<Stats>? stats) {
if (stats == null) return null;
return stats
.map(
(s) => {
'str': s.str,
'con': s.con,
'dex': s.dex,
'int': s.intelligence,
'wis': s.wis,
'cha': s.cha,
'hpMax': s.hpMax,
'mpMax': s.mpMax,
},
)
.toList();
}
/// JSON → Stats 리스트
List<Stats>? _statsListFromJson(List<dynamic>? json) {
if (json == null) return null;
return json.map((e) {
final m = e as Map<String, dynamic>;
return Stats(
str: m['str'] as int? ?? 0,
con: m['con'] as int? ?? 0,
dex: m['dex'] as int? ?? 0,
intelligence: m['int'] as int? ?? 0,
wis: m['wis'] as int? ?? 0,
cha: m['cha'] as int? ?? 0,
hpMax: m['hpMax'] as int? ?? 0,
mpMax: m['mpMax'] as int? ?? 0,
);
}).toList();
}
/// DateTime → JSON (밀리초 타임스탬프)
int? _dateTimeToJson(DateTime? dateTime) {
return dateTime?.millisecondsSinceEpoch;
}
/// JSON → DateTime
DateTime? _dateTimeFromJson(int? timestamp) {
if (timestamp == null) return null;
return DateTime.fromMillisecondsSinceEpoch(timestamp);
}

View File

@@ -0,0 +1,444 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'monetization_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
MonetizationState _$MonetizationStateFromJson(Map<String, dynamic> json) {
return _MonetizationState.fromJson(json);
}
/// @nodoc
mixin _$MonetizationState {
/// IAP 광고 제거 구매 여부
bool get adRemovalPurchased => throw _privateConstructorUsedError;
/// 캐릭터 생성 굴리기 남은 횟수 (0-5)
int get rollsRemaining => throw _privateConstructorUsedError;
/// 되돌리기 남은 횟수
int get undoRemaining => throw _privateConstructorUsedError;
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
List<Stats>? get rollHistory => throw _privateConstructorUsedError;
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
int? get autoReviveEndMs => throw _privateConstructorUsedError;
/// 5배속 버프 종료 시점 (elapsedMs 기준)
int? get speedBoostEndMs => throw _privateConstructorUsedError;
/// 마지막 플레이 시각 (복귀 보상 계산용)
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
DateTime? get lastPlayTime => throw _privateConstructorUsedError;
/// 미개봉 보물 상자 개수
int get pendingChests => throw _privateConstructorUsedError;
/// 행운의 부적 버프 종료 시점 (elapsedMs 기준)
int? get luckyCharmEndMs => throw _privateConstructorUsedError;
/// Serializes this MonetizationState to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of MonetizationState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$MonetizationStateCopyWith<MonetizationState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $MonetizationStateCopyWith<$Res> {
factory $MonetizationStateCopyWith(
MonetizationState value,
$Res Function(MonetizationState) then,
) = _$MonetizationStateCopyWithImpl<$Res, MonetizationState>;
@useResult
$Res call({
bool adRemovalPurchased,
int rollsRemaining,
int undoRemaining,
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
List<Stats>? rollHistory,
int? autoReviveEndMs,
int? speedBoostEndMs,
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
DateTime? lastPlayTime,
int pendingChests,
int? luckyCharmEndMs,
});
}
/// @nodoc
class _$MonetizationStateCopyWithImpl<$Res, $Val extends MonetizationState>
implements $MonetizationStateCopyWith<$Res> {
_$MonetizationStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of MonetizationState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? adRemovalPurchased = null,
Object? rollsRemaining = null,
Object? undoRemaining = null,
Object? rollHistory = freezed,
Object? autoReviveEndMs = freezed,
Object? speedBoostEndMs = freezed,
Object? lastPlayTime = freezed,
Object? pendingChests = null,
Object? luckyCharmEndMs = freezed,
}) {
return _then(
_value.copyWith(
adRemovalPurchased: null == adRemovalPurchased
? _value.adRemovalPurchased
: adRemovalPurchased // ignore: cast_nullable_to_non_nullable
as bool,
rollsRemaining: null == rollsRemaining
? _value.rollsRemaining
: rollsRemaining // ignore: cast_nullable_to_non_nullable
as int,
undoRemaining: null == undoRemaining
? _value.undoRemaining
: undoRemaining // ignore: cast_nullable_to_non_nullable
as int,
rollHistory: freezed == rollHistory
? _value.rollHistory
: rollHistory // ignore: cast_nullable_to_non_nullable
as List<Stats>?,
autoReviveEndMs: freezed == autoReviveEndMs
? _value.autoReviveEndMs
: autoReviveEndMs // ignore: cast_nullable_to_non_nullable
as int?,
speedBoostEndMs: freezed == speedBoostEndMs
? _value.speedBoostEndMs
: speedBoostEndMs // ignore: cast_nullable_to_non_nullable
as int?,
lastPlayTime: freezed == lastPlayTime
? _value.lastPlayTime
: lastPlayTime // ignore: cast_nullable_to_non_nullable
as DateTime?,
pendingChests: null == pendingChests
? _value.pendingChests
: pendingChests // ignore: cast_nullable_to_non_nullable
as int,
luckyCharmEndMs: freezed == luckyCharmEndMs
? _value.luckyCharmEndMs
: luckyCharmEndMs // ignore: cast_nullable_to_non_nullable
as int?,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$MonetizationStateImplCopyWith<$Res>
implements $MonetizationStateCopyWith<$Res> {
factory _$$MonetizationStateImplCopyWith(
_$MonetizationStateImpl value,
$Res Function(_$MonetizationStateImpl) then,
) = __$$MonetizationStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
bool adRemovalPurchased,
int rollsRemaining,
int undoRemaining,
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
List<Stats>? rollHistory,
int? autoReviveEndMs,
int? speedBoostEndMs,
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
DateTime? lastPlayTime,
int pendingChests,
int? luckyCharmEndMs,
});
}
/// @nodoc
class __$$MonetizationStateImplCopyWithImpl<$Res>
extends _$MonetizationStateCopyWithImpl<$Res, _$MonetizationStateImpl>
implements _$$MonetizationStateImplCopyWith<$Res> {
__$$MonetizationStateImplCopyWithImpl(
_$MonetizationStateImpl _value,
$Res Function(_$MonetizationStateImpl) _then,
) : super(_value, _then);
/// Create a copy of MonetizationState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? adRemovalPurchased = null,
Object? rollsRemaining = null,
Object? undoRemaining = null,
Object? rollHistory = freezed,
Object? autoReviveEndMs = freezed,
Object? speedBoostEndMs = freezed,
Object? lastPlayTime = freezed,
Object? pendingChests = null,
Object? luckyCharmEndMs = freezed,
}) {
return _then(
_$MonetizationStateImpl(
adRemovalPurchased: null == adRemovalPurchased
? _value.adRemovalPurchased
: adRemovalPurchased // ignore: cast_nullable_to_non_nullable
as bool,
rollsRemaining: null == rollsRemaining
? _value.rollsRemaining
: rollsRemaining // ignore: cast_nullable_to_non_nullable
as int,
undoRemaining: null == undoRemaining
? _value.undoRemaining
: undoRemaining // ignore: cast_nullable_to_non_nullable
as int,
rollHistory: freezed == rollHistory
? _value._rollHistory
: rollHistory // ignore: cast_nullable_to_non_nullable
as List<Stats>?,
autoReviveEndMs: freezed == autoReviveEndMs
? _value.autoReviveEndMs
: autoReviveEndMs // ignore: cast_nullable_to_non_nullable
as int?,
speedBoostEndMs: freezed == speedBoostEndMs
? _value.speedBoostEndMs
: speedBoostEndMs // ignore: cast_nullable_to_non_nullable
as int?,
lastPlayTime: freezed == lastPlayTime
? _value.lastPlayTime
: lastPlayTime // ignore: cast_nullable_to_non_nullable
as DateTime?,
pendingChests: null == pendingChests
? _value.pendingChests
: pendingChests // ignore: cast_nullable_to_non_nullable
as int,
luckyCharmEndMs: freezed == luckyCharmEndMs
? _value.luckyCharmEndMs
: luckyCharmEndMs // ignore: cast_nullable_to_non_nullable
as int?,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$MonetizationStateImpl extends _MonetizationState {
const _$MonetizationStateImpl({
this.adRemovalPurchased = false,
this.rollsRemaining = 5,
this.undoRemaining = 1,
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
final List<Stats>? rollHistory,
this.autoReviveEndMs,
this.speedBoostEndMs,
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
this.lastPlayTime,
this.pendingChests = 0,
this.luckyCharmEndMs,
}) : _rollHistory = rollHistory,
super._();
factory _$MonetizationStateImpl.fromJson(Map<String, dynamic> json) =>
_$$MonetizationStateImplFromJson(json);
/// IAP 광고 제거 구매 여부
@override
@JsonKey()
final bool adRemovalPurchased;
/// 캐릭터 생성 굴리기 남은 횟수 (0-5)
@override
@JsonKey()
final int rollsRemaining;
/// 되돌리기 남은 횟수
@override
@JsonKey()
final int undoRemaining;
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
final List<Stats>? _rollHistory;
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
@override
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
List<Stats>? get rollHistory {
final value = _rollHistory;
if (value == null) return null;
if (_rollHistory is EqualUnmodifiableListView) return _rollHistory;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
@override
final int? autoReviveEndMs;
/// 5배속 버프 종료 시점 (elapsedMs 기준)
@override
final int? speedBoostEndMs;
/// 마지막 플레이 시각 (복귀 보상 계산용)
@override
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
final DateTime? lastPlayTime;
/// 미개봉 보물 상자 개수
@override
@JsonKey()
final int pendingChests;
/// 행운의 부적 버프 종료 시점 (elapsedMs 기준)
@override
final int? luckyCharmEndMs;
@override
String toString() {
return 'MonetizationState(adRemovalPurchased: $adRemovalPurchased, rollsRemaining: $rollsRemaining, undoRemaining: $undoRemaining, rollHistory: $rollHistory, autoReviveEndMs: $autoReviveEndMs, speedBoostEndMs: $speedBoostEndMs, lastPlayTime: $lastPlayTime, pendingChests: $pendingChests, luckyCharmEndMs: $luckyCharmEndMs)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$MonetizationStateImpl &&
(identical(other.adRemovalPurchased, adRemovalPurchased) ||
other.adRemovalPurchased == adRemovalPurchased) &&
(identical(other.rollsRemaining, rollsRemaining) ||
other.rollsRemaining == rollsRemaining) &&
(identical(other.undoRemaining, undoRemaining) ||
other.undoRemaining == undoRemaining) &&
const DeepCollectionEquality().equals(
other._rollHistory,
_rollHistory,
) &&
(identical(other.autoReviveEndMs, autoReviveEndMs) ||
other.autoReviveEndMs == autoReviveEndMs) &&
(identical(other.speedBoostEndMs, speedBoostEndMs) ||
other.speedBoostEndMs == speedBoostEndMs) &&
(identical(other.lastPlayTime, lastPlayTime) ||
other.lastPlayTime == lastPlayTime) &&
(identical(other.pendingChests, pendingChests) ||
other.pendingChests == pendingChests) &&
(identical(other.luckyCharmEndMs, luckyCharmEndMs) ||
other.luckyCharmEndMs == luckyCharmEndMs));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
adRemovalPurchased,
rollsRemaining,
undoRemaining,
const DeepCollectionEquality().hash(_rollHistory),
autoReviveEndMs,
speedBoostEndMs,
lastPlayTime,
pendingChests,
luckyCharmEndMs,
);
/// Create a copy of MonetizationState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$MonetizationStateImplCopyWith<_$MonetizationStateImpl> get copyWith =>
__$$MonetizationStateImplCopyWithImpl<_$MonetizationStateImpl>(
this,
_$identity,
);
@override
Map<String, dynamic> toJson() {
return _$$MonetizationStateImplToJson(this);
}
}
abstract class _MonetizationState extends MonetizationState {
const factory _MonetizationState({
final bool adRemovalPurchased,
final int rollsRemaining,
final int undoRemaining,
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
final List<Stats>? rollHistory,
final int? autoReviveEndMs,
final int? speedBoostEndMs,
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
final DateTime? lastPlayTime,
final int pendingChests,
final int? luckyCharmEndMs,
}) = _$MonetizationStateImpl;
const _MonetizationState._() : super._();
factory _MonetizationState.fromJson(Map<String, dynamic> json) =
_$MonetizationStateImpl.fromJson;
/// IAP 광고 제거 구매 여부
@override
bool get adRemovalPurchased;
/// 캐릭터 생성 굴리기 남은 횟수 (0-5)
@override
int get rollsRemaining;
/// 되돌리기 남은 횟수
@override
int get undoRemaining;
/// 되돌리기용 스탯 히스토리 (JSON 변환 커스텀)
@override
@JsonKey(fromJson: _statsListFromJson, toJson: _statsListToJson)
List<Stats>? get rollHistory;
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
@override
int? get autoReviveEndMs;
/// 5배속 버프 종료 시점 (elapsedMs 기준)
@override
int? get speedBoostEndMs;
/// 마지막 플레이 시각 (복귀 보상 계산용)
@override
@JsonKey(fromJson: _dateTimeFromJson, toJson: _dateTimeToJson)
DateTime? get lastPlayTime;
/// 미개봉 보물 상자 개수
@override
int get pendingChests;
/// 행운의 부적 버프 종료 시점 (elapsedMs 기준)
@override
int? get luckyCharmEndMs;
/// Create a copy of MonetizationState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$MonetizationStateImplCopyWith<_$MonetizationStateImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,35 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'monetization_state.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$MonetizationStateImpl _$$MonetizationStateImplFromJson(
Map<String, dynamic> json,
) => _$MonetizationStateImpl(
adRemovalPurchased: json['adRemovalPurchased'] as bool? ?? false,
rollsRemaining: (json['rollsRemaining'] as num?)?.toInt() ?? 5,
undoRemaining: (json['undoRemaining'] as num?)?.toInt() ?? 1,
rollHistory: _statsListFromJson(json['rollHistory'] as List?),
autoReviveEndMs: (json['autoReviveEndMs'] as num?)?.toInt(),
speedBoostEndMs: (json['speedBoostEndMs'] as num?)?.toInt(),
lastPlayTime: _dateTimeFromJson((json['lastPlayTime'] as num?)?.toInt()),
pendingChests: (json['pendingChests'] as num?)?.toInt() ?? 0,
luckyCharmEndMs: (json['luckyCharmEndMs'] as num?)?.toInt(),
);
Map<String, dynamic> _$$MonetizationStateImplToJson(
_$MonetizationStateImpl instance,
) => <String, dynamic>{
'adRemovalPurchased': instance.adRemovalPurchased,
'rollsRemaining': instance.rollsRemaining,
'undoRemaining': instance.undoRemaining,
'rollHistory': _statsListToJson(instance.rollHistory),
'autoReviveEndMs': instance.autoReviveEndMs,
'speedBoostEndMs': instance.speedBoostEndMs,
'lastPlayTime': _dateTimeToJson(instance.lastPlayTime),
'pendingChests': instance.pendingChests,
'luckyCharmEndMs': instance.luckyCharmEndMs,
};

View File

@@ -22,6 +22,7 @@ class MonsterCombatStats {
required this.level,
required this.atk,
required this.def,
required this.magDef,
required this.hpMax,
required this.hpCurrent,
required this.criRate,
@@ -41,9 +42,12 @@ class MonsterCombatStats {
/// 공격력
final int atk;
/// 방어력
/// 물리 방어력
final int def;
/// 마법 방어력
final int magDef;
/// 최대 HP
final int hpMax;
@@ -96,6 +100,7 @@ class MonsterCombatStats {
int? level,
int? atk,
int? def,
int? magDef,
int? hpMax,
int? hpCurrent,
double? criRate,
@@ -110,6 +115,7 @@ class MonsterCombatStats {
level: level ?? this.level,
atk: atk ?? this.atk,
def: def ?? this.def,
magDef: magDef ?? this.magDef,
hpMax: hpMax ?? this.hpMax,
hpCurrent: hpCurrent ?? this.hpCurrent,
criRate: criRate ?? this.criRate,
@@ -173,11 +179,16 @@ class MonsterCombatStats {
MonsterSpeedType.slow => 1400,
};
// 마법 방어력: 물리 방어력의 70~130% (레벨에 따라 변동)
final magDefRatio = 0.7 + (level % 60) * 0.01; // 0.7 ~ 1.3
final magDef = (baseStats.def * magDefRatio).round();
return MonsterCombatStats(
name: name,
level: level,
atk: baseStats.atk,
def: baseStats.def,
magDef: magDef,
hpMax: adjustedHp,
hpCurrent: adjustedHp,
criRate: criRate,
@@ -202,6 +213,7 @@ class MonsterCombatStats {
level: bossLevel,
atk: bossStats.atk,
def: bossStats.def,
magDef: (bossStats.def * 1.2).round(), // 보스는 마법 방어력 20% 증가
hpMax: bossStats.hp,
hpCurrent: bossStats.hp,
criRate: 0.25, // 보스 크리티컬 확률 25%
@@ -244,6 +256,7 @@ class MonsterCombatStats {
level: 1,
atk: 8,
def: 3,
magDef: 3,
hpMax: 35,
hpCurrent: 35,
criRate: 0.02,
@@ -264,6 +277,7 @@ class MonsterCombatStats {
level: 0, // PvP에서는 레벨 페널티 없음
atk: stats.atk,
def: stats.def,
magDef: stats.magDef,
hpMax: stats.hpMax,
hpCurrent: stats.hpMax, // 풀 HP로 시작
criRate: stats.criRate,

View File

@@ -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, // 골드
};
}

View File

@@ -60,39 +60,28 @@ class Potion {
/// 물약 인벤토리 상태
///
/// 보유 물약 수량 및 전투 중 사용 기록 관리
/// 보유 물약 수량 관리 (쿨타임은 CombatState에서 관리)
class PotionInventory {
const PotionInventory({
this.potions = const {},
this.usedInBattle = const {},
});
const PotionInventory({this.potions = const {}});
/// 보유 물약 (물약 ID → 수량)
final Map<String, int> potions;
/// 현재 전투에서 사용한 물약 종류
final Set<PotionType> usedInBattle;
/// 물약 보유 여부
bool hasPotion(String potionId) => (potions[potionId] ?? 0) > 0;
/// 물약 수량 조회
int getQuantity(String potionId) => potions[potionId] ?? 0;
/// 특정 종류 물약 사용 가능 여부
///
/// 전투당 종류별 1회 제한 체크
bool canUseType(PotionType type) => !usedInBattle.contains(type);
/// 물약 추가
PotionInventory addPotion(String potionId, [int count = 1]) {
final newPotions = Map<String, int>.from(potions);
newPotions[potionId] = (newPotions[potionId] ?? 0) + count;
return PotionInventory(potions: newPotions, usedInBattle: usedInBattle);
return PotionInventory(potions: newPotions);
}
/// 물약 사용 (수량 감소)
PotionInventory usePotion(String potionId, PotionType type) {
PotionInventory usePotion(String potionId) {
final currentQty = potions[potionId] ?? 0;
if (currentQty <= 0) return this;
@@ -102,26 +91,13 @@ class PotionInventory {
newPotions.remove(potionId);
}
final newUsed = Set<PotionType>.from(usedInBattle)..add(type);
return PotionInventory(potions: newPotions, usedInBattle: newUsed);
}
/// 전투 종료 시 사용 기록 초기화
PotionInventory resetBattleUsage() {
return PotionInventory(potions: potions, usedInBattle: const {});
return PotionInventory(potions: newPotions);
}
/// 빈 인벤토리
static const empty = PotionInventory();
PotionInventory copyWith({
Map<String, int>? potions,
Set<PotionType>? usedInBattle,
}) {
return PotionInventory(
potions: potions ?? this.potions,
usedInBattle: usedInBattle ?? this.usedInBattle,
);
PotionInventory copyWith({Map<String, int>? potions}) {
return PotionInventory(potions: potions ?? this.potions);
}
}

View File

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

View File

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

View File

@@ -20,9 +20,6 @@ enum PassiveType {
/// MP 배율 보너스
mpBonus,
/// 사망 시 장비 보존
deathEquipmentPreserve,
}
/// 패시브 능력 (passive ability)

View File

@@ -2,6 +2,7 @@ import 'dart:collection';
import 'package:asciineverdie/src/core/model/equipment_item.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/monetization_state.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
@@ -9,7 +10,8 @@ import 'package:asciineverdie/src/core/model/game_state.dart';
/// 세이브 파일 버전
/// - v2: 장비 이름만 저장 (레거시)
/// - v3: 장비 전체 정보 저장 (level, rarity, stats 포함)
const int kSaveVersion = 3;
/// - v4: MonetizationState 추가, DeathInfo.lostItem 추가
const int kSaveVersion = 4;
class GameSave {
GameSave({
@@ -23,9 +25,14 @@ class GameSave {
required this.progress,
required this.queue,
this.cheatsEnabled = false,
this.monetization,
});
factory GameSave.fromState(GameState state, {bool cheatsEnabled = false}) {
factory GameSave.fromState(
GameState state, {
bool cheatsEnabled = false,
MonetizationState? monetization,
}) {
return GameSave(
version: kSaveVersion,
rngState: state.rng.state,
@@ -37,6 +44,7 @@ class GameSave {
progress: state.progress,
queue: state.queue,
cheatsEnabled: cheatsEnabled,
monetization: monetization,
);
}
@@ -51,6 +59,9 @@ class GameSave {
final QueueState queue;
final bool cheatsEnabled;
/// 수익화 시스템 상태 (v4+)
final MonetizationState? monetization;
Map<String, dynamic> toJson() {
return {
'version': version,
@@ -132,6 +143,7 @@ class GameSave {
},
)
.toList(),
if (monetization != null) 'monetization': monetization!.toJson(),
};
}
@@ -244,6 +256,9 @@ class GameSave {
}),
),
),
monetization: _monetizationFromJson(
json['monetization'] as Map<String, dynamic>?,
),
);
}
@@ -355,3 +370,12 @@ Equipment _equipmentFromJson(Map<String, dynamic> json, int version) {
bestIndex: json['bestIndex'] as int? ?? 0,
);
}
/// MonetizationState 역직렬화 (v4+ 마이그레이션)
///
/// - v3 이하: null (기본값 사용)
/// - v4 이상: 저장된 상태 로드
MonetizationState? _monetizationFromJson(Map<String, dynamic>? json) {
if (json == null) return null;
return MonetizationState.fromJson(json);
}

View File

@@ -0,0 +1,279 @@
/// 세션 통계 (Session Statistics)
///
/// GameStatistics에서 분리된 현재 게임 세션의 통계 모델.
class SessionStatistics {
const SessionStatistics({
required this.playTimeMs,
required this.monstersKilled,
required this.goldEarned,
required this.goldSpent,
required this.skillsUsed,
required this.criticalHits,
required this.maxCriticalStreak,
required this.currentCriticalStreak,
required this.totalDamageDealt,
required this.totalDamageTaken,
required this.potionsUsed,
required this.itemsSold,
required this.questsCompleted,
required this.deathCount,
required this.bossesDefeated,
required this.levelUps,
});
/// 플레이 시간 (밀리초)
final int playTimeMs;
/// 처치한 몬스터 수
final int monstersKilled;
/// 획득한 골드 총량
final int goldEarned;
/// 소비한 골드 총량
final int goldSpent;
/// 사용한 스킬 횟수
final int skillsUsed;
/// 크리티컬 히트 횟수
final int criticalHits;
/// 최대 연속 크리티컬
final int maxCriticalStreak;
/// 현재 연속 크리티컬 (내부 추적용)
final int currentCriticalStreak;
/// 총 입힌 데미지
final int totalDamageDealt;
/// 총 받은 데미지
final int totalDamageTaken;
/// 사용한 물약 수
final int potionsUsed;
/// 판매한 아이템 수
final int itemsSold;
/// 완료한 퀘스트 수
final int questsCompleted;
/// 사망 횟수
final int deathCount;
/// 처치한 보스 수
final int bossesDefeated;
/// 레벨업 횟수
final int levelUps;
/// 플레이 시간 Duration
Duration get playTime => Duration(milliseconds: playTimeMs);
/// 플레이 시간 포맷 (HH:MM:SS)
String get formattedPlayTime {
final hours = playTime.inHours;
final minutes = playTime.inMinutes % 60;
final seconds = playTime.inSeconds % 60;
return '${hours.toString().padLeft(2, '0')}:'
'${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
/// 평균 DPS (damage per second)
double get averageDps {
if (playTimeMs <= 0) return 0;
return totalDamageDealt / (playTimeMs / 1000);
}
/// 킬당 평균 골드
double get goldPerKill {
if (monstersKilled <= 0) return 0;
return goldEarned / monstersKilled;
}
/// 크리티컬 비율
double get criticalRate {
if (skillsUsed <= 0) return 0;
return criticalHits / skillsUsed;
}
/// 빈 세션 통계
factory SessionStatistics.empty() => const SessionStatistics(
playTimeMs: 0,
monstersKilled: 0,
goldEarned: 0,
goldSpent: 0,
skillsUsed: 0,
criticalHits: 0,
maxCriticalStreak: 0,
currentCriticalStreak: 0,
totalDamageDealt: 0,
totalDamageTaken: 0,
potionsUsed: 0,
itemsSold: 0,
questsCompleted: 0,
deathCount: 0,
bossesDefeated: 0,
levelUps: 0,
);
// ============================================================================
// 이벤트 기록 메서드
// ============================================================================
/// 몬스터 처치 기록
SessionStatistics recordKill({bool isBoss = false}) {
return copyWith(
monstersKilled: monstersKilled + 1,
bossesDefeated: isBoss ? bossesDefeated + 1 : bossesDefeated,
);
}
/// 골드 획득 기록
SessionStatistics recordGoldEarned(int amount) {
return copyWith(goldEarned: goldEarned + amount);
}
/// 골드 소비 기록
SessionStatistics recordGoldSpent(int amount) {
return copyWith(goldSpent: goldSpent + amount);
}
/// 스킬 사용 기록
SessionStatistics recordSkillUse({required bool isCritical}) {
final newCriticalStreak = isCritical ? currentCriticalStreak + 1 : 0;
final newMaxStreak = newCriticalStreak > maxCriticalStreak
? newCriticalStreak
: maxCriticalStreak;
return copyWith(
skillsUsed: skillsUsed + 1,
criticalHits: isCritical ? criticalHits + 1 : criticalHits,
currentCriticalStreak: newCriticalStreak,
maxCriticalStreak: newMaxStreak,
);
}
/// 데미지 기록
SessionStatistics recordDamage({int dealt = 0, int taken = 0}) {
return copyWith(
totalDamageDealt: totalDamageDealt + dealt,
totalDamageTaken: totalDamageTaken + taken,
);
}
/// 물약 사용 기록
SessionStatistics recordPotionUse() {
return copyWith(potionsUsed: potionsUsed + 1);
}
/// 아이템 판매 기록
SessionStatistics recordItemSold(int count) {
return copyWith(itemsSold: itemsSold + count);
}
/// 퀘스트 완료 기록
SessionStatistics recordQuestComplete() {
return copyWith(questsCompleted: questsCompleted + 1);
}
/// 사망 기록
SessionStatistics recordDeath() {
return copyWith(deathCount: deathCount + 1);
}
/// 레벨업 기록
SessionStatistics recordLevelUp() {
return copyWith(levelUps: levelUps + 1);
}
/// 플레이 시간 업데이트
SessionStatistics updatePlayTime(int elapsedMs) {
return copyWith(playTimeMs: elapsedMs);
}
SessionStatistics copyWith({
int? playTimeMs,
int? monstersKilled,
int? goldEarned,
int? goldSpent,
int? skillsUsed,
int? criticalHits,
int? maxCriticalStreak,
int? currentCriticalStreak,
int? totalDamageDealt,
int? totalDamageTaken,
int? potionsUsed,
int? itemsSold,
int? questsCompleted,
int? deathCount,
int? bossesDefeated,
int? levelUps,
}) {
return SessionStatistics(
playTimeMs: playTimeMs ?? this.playTimeMs,
monstersKilled: monstersKilled ?? this.monstersKilled,
goldEarned: goldEarned ?? this.goldEarned,
goldSpent: goldSpent ?? this.goldSpent,
skillsUsed: skillsUsed ?? this.skillsUsed,
criticalHits: criticalHits ?? this.criticalHits,
maxCriticalStreak: maxCriticalStreak ?? this.maxCriticalStreak,
currentCriticalStreak:
currentCriticalStreak ?? this.currentCriticalStreak,
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
potionsUsed: potionsUsed ?? this.potionsUsed,
itemsSold: itemsSold ?? this.itemsSold,
questsCompleted: questsCompleted ?? this.questsCompleted,
deathCount: deathCount ?? this.deathCount,
bossesDefeated: bossesDefeated ?? this.bossesDefeated,
levelUps: levelUps ?? this.levelUps,
);
}
/// JSON 직렬화
Map<String, dynamic> toJson() {
return {
'playTimeMs': playTimeMs,
'monstersKilled': monstersKilled,
'goldEarned': goldEarned,
'goldSpent': goldSpent,
'skillsUsed': skillsUsed,
'criticalHits': criticalHits,
'maxCriticalStreak': maxCriticalStreak,
'totalDamageDealt': totalDamageDealt,
'totalDamageTaken': totalDamageTaken,
'potionsUsed': potionsUsed,
'itemsSold': itemsSold,
'questsCompleted': questsCompleted,
'deathCount': deathCount,
'bossesDefeated': bossesDefeated,
'levelUps': levelUps,
};
}
/// JSON 역직렬화
factory SessionStatistics.fromJson(Map<String, dynamic> json) {
return SessionStatistics(
playTimeMs: json['playTimeMs'] as int? ?? 0,
monstersKilled: json['monstersKilled'] as int? ?? 0,
goldEarned: json['goldEarned'] as int? ?? 0,
goldSpent: json['goldSpent'] as int? ?? 0,
skillsUsed: json['skillsUsed'] as int? ?? 0,
criticalHits: json['criticalHits'] as int? ?? 0,
maxCriticalStreak: json['maxCriticalStreak'] as int? ?? 0,
currentCriticalStreak: 0, // 세션간 유지 안 함
totalDamageDealt: json['totalDamageDealt'] as int? ?? 0,
totalDamageTaken: json['totalDamageTaken'] as int? ?? 0,
potionsUsed: json['potionsUsed'] as int? ?? 0,
itemsSold: json['itemsSold'] as int? ?? 0,
questsCompleted: json['questsCompleted'] as int? ?? 0,
deathCount: json['deathCount'] as int? ?? 0,
bossesDefeated: json['bossesDefeated'] as int? ?? 0,
levelUps: json['levelUps'] as int? ?? 0,
);
}
}

View File

@@ -4,8 +4,9 @@
/// 스펠 랭크에 따른 스킬 배율 계산
///
/// 랭크 1: 1.0x, 랭크 2: 1.15x, 랭크 3: 1.30x, ...
double getRankMultiplier(int rank) => 1.0 + (rank - 1) * 0.15;
/// 랭크 1: 1.0x, 랭크 2: 1.08x, 랭크 3: 1.16x, ... 최대 1.72x (rank 10)
/// Phase 3 밸런스: 0.15 → 0.08로 하향
double getRankMultiplier(int rank) => 1.0 + (rank - 1) * 0.08;
/// 랭크에 따른 쿨타임 감소율 계산
///
@@ -38,6 +39,18 @@ enum SkillType {
debuff,
}
/// 데미지 타입 (물리/마법 구분)
enum DamageType {
/// 물리 공격 - STR + atk 기반, 적 def로 방어
physical,
/// 마법 공격 - INT + magAtk 기반, 적 magDef로 방어
magical,
/// 하이브리드 - (atk + magAtk) / 2, (def + magDef) / 2
hybrid,
}
/// 스킬 속성 (하이브리드: 코드 + 시스템)
enum SkillElement {
/// 논리 (Logic) - 순수 데미지
@@ -118,6 +131,7 @@ class Skill {
required this.cooldownMs,
required this.power,
this.tier = 1,
this.damageType = DamageType.physical,
this.damageMultiplier = 1.0,
this.healAmount = 0,
this.healPercent = 0.0,
@@ -137,6 +151,9 @@ class Skill {
/// 스킬 티어 (1~5, 높을수록 강함)
final int tier;
/// 데미지 타입 (물리/마법/하이브리드)
final DamageType damageType;
/// 스킬 ID
final String id;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,106 @@
import 'package:asciineverdie/src/core/model/equipment_item.dart';
/// 상자 보상 타입
enum ChestRewardType {
/// 장비 아이템
equipment,
/// 포션
potion,
/// 골드
gold,
/// 경험치
experience,
}
/// 상자 내용물 (개봉 결과)
class ChestReward {
const ChestReward._({
required this.type,
this.equipment,
this.potionId,
this.potionCount,
this.gold,
this.experience,
});
/// 장비 보상 생성
factory ChestReward.equipment(EquipmentItem item) {
return ChestReward._(type: ChestRewardType.equipment, equipment: item);
}
/// 포션 보상 생성
factory ChestReward.potion(String potionId, int count) {
return ChestReward._(
type: ChestRewardType.potion,
potionId: potionId,
potionCount: count,
);
}
/// 골드 보상 생성
factory ChestReward.gold(int amount) {
return ChestReward._(type: ChestRewardType.gold, gold: amount);
}
/// 경험치 보상 생성
factory ChestReward.experience(int amount) {
return ChestReward._(type: ChestRewardType.experience, experience: amount);
}
/// 보상 타입
final ChestRewardType type;
/// 장비 (type == equipment일 때)
final EquipmentItem? equipment;
/// 포션 ID (type == potion일 때)
final String? potionId;
/// 포션 수량 (type == potion일 때)
final int? potionCount;
/// 골드 (type == gold일 때)
final int? gold;
/// 경험치 (type == experience일 때)
final int? experience;
/// 장비 보상인지 여부
bool get isEquipment => type == ChestRewardType.equipment;
/// 포션 보상인지 여부
bool get isPotion => type == ChestRewardType.potion;
/// 골드 보상인지 여부
bool get isGold => type == ChestRewardType.gold;
/// 경험치 보상인지 여부
bool get isExperience => type == ChestRewardType.experience;
}
/// 복귀 보상 상자 데이터
class ReturnChestReward {
const ReturnChestReward({
required this.hoursAway,
required this.chestCount,
required this.bonusChestCount,
});
/// 떠나있던 시간 (시간 단위)
final int hoursAway;
/// 기본 상자 개수
final int chestCount;
/// 보너스 상자 개수 (광고 시청 시 추가)
final int bonusChestCount;
/// 총 상자 개수 (광고 포함)
int get totalChests => chestCount + bonusChestCount;
/// 보상이 있는지 여부
bool get hasReward => chestCount > 0;
}

View File

@@ -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),

View File

@@ -1,4 +1,5 @@
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/monetization_state.dart';
import 'package:asciineverdie/src/core/model/save_data.dart';
import 'package:asciineverdie/src/core/storage/save_repository.dart';
import 'package:asciineverdie/src/core/storage/save_service.dart'
@@ -13,23 +14,36 @@ class SaveManager {
/// Save current game state to disk. [fileName] may be absolute or relative.
/// Returns outcome with error on failure.
///
/// [monetization] 저장 시 lastPlayTime을 현재 시간으로 자동 업데이트
Future<SaveOutcome> saveState(
GameState state, {
String? fileName,
bool cheatsEnabled = false,
MonetizationState? monetization,
}) {
final save = GameSave.fromState(state, cheatsEnabled: cheatsEnabled);
// lastPlayTime을 현재 시간으로 업데이트
final updatedMonetization = (monetization ?? MonetizationState.initial())
.copyWith(lastPlayTime: DateTime.now());
final save = GameSave.fromState(
state,
cheatsEnabled: cheatsEnabled,
monetization: updatedMonetization,
);
return _repo.save(save, fileName ?? defaultFileName);
}
/// Load game state from disk. [fileName] may be absolute (e.g., file picker).
/// Returns outcome + optional state + cheatsEnabled flag.
Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async {
/// Returns outcome + optional state + cheatsEnabled flag + monetization state.
Future<(SaveOutcome, GameState?, bool, MonetizationState?)> loadState({
String? fileName,
}) async {
final (outcome, save) = await _repo.load(fileName ?? defaultFileName);
if (!outcome.success || save == null) {
return (outcome, null, false);
return (outcome, null, false, null);
}
return (outcome, save.toState(), save.cheatsEnabled);
return (outcome, save.toState(), save.cheatsEnabled, save.monetization);
}
/// 저장 파일 목록 조회

View File

@@ -1,11 +1,9 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 앱 설정 저장소 (SharedPreferences 기반)
///
/// 테마, 언어, 사운드 등 사용자 설정을 로컬에 저장
/// 언어, 사운드 등 사용자 설정을 로컬에 저장
class SettingsRepository {
static const _keyThemeMode = 'theme_mode';
static const _keyLocale = 'locale';
static const _keyBgmVolume = 'bgm_volume';
static const _keySfxVolume = 'sfx_volume';
@@ -18,29 +16,6 @@ class SettingsRepository {
_prefs ??= await SharedPreferences.getInstance();
}
/// 테마 모드 저장
Future<void> saveThemeMode(ThemeMode mode) async {
await init();
final value = switch (mode) {
ThemeMode.light => 'light',
ThemeMode.dark => 'dark',
ThemeMode.system => 'system',
};
await _prefs!.setString(_keyThemeMode, value);
}
/// 테마 모드 불러오기
Future<ThemeMode> loadThemeMode() async {
await init();
final value = _prefs!.getString(_keyThemeMode);
return switch (value) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
'system' => ThemeMode.system,
_ => ThemeMode.system, // 기본값
};
}
/// 언어 설정 저장
Future<void> saveLocale(String locale) async {
await init();

View File

@@ -2,23 +2,21 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/engine/arena_service.dart';
import 'package:asciineverdie/src/core/model/arena_match.dart';
import 'package:asciineverdie/src/core/model/combat_event.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/animation/race_character_frames.dart';
import 'package:asciineverdie/src/shared/animation/race_character_frames.dart';
import 'package:asciineverdie/src/features/arena/widgets/arena_combat_log.dart';
import 'package:asciineverdie/src/features/arena/widgets/arena_hp_bar.dart';
import 'package:asciineverdie/src/features/arena/widgets/arena_result_panel.dart';
import 'package:asciineverdie/src/shared/widgets/ascii_disintegrate_widget.dart';
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
// 임시 문자열 (추후 l10n으로 이동)
const _battleTitle = 'ARENA BATTLE';
const _hpLabel = 'HP';
/// 아레나 전투 화면
///
/// ASCII 애니메이션 기반 턴제 전투 표시
@@ -438,7 +436,7 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
backgroundColor: RetroColors.backgroundOf(context),
appBar: AppBar(
title: Text(
_battleTitle,
L10n.of(context).arenaBattleTitle,
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15),
),
centerTitle: true,
@@ -451,7 +449,18 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
// 턴 표시
_buildTurnIndicator(),
// HP 바 (레트로 세그먼트 스타일)
_buildRetroHpBars(),
ArenaHpBars(
challengerName: widget.match.challenger.characterName,
challengerHp: _challengerHp,
challengerHpMax: _challengerHpMax,
challengerFlashAnimation: _challengerFlashAnimation,
challengerHpChange: _challengerHpChange,
opponentName: widget.match.opponent.characterName,
opponentHp: _opponentHp,
opponentHpMax: _opponentHpMax,
opponentFlashAnimation: _opponentFlashAnimation,
opponentHpChange: _opponentHpChange,
),
// 전투 이벤트 아이콘 (HP 바와 애니메이션 사이)
_buildCombatEventIcons(),
// ASCII 애니메이션 (전투 중 / 종료 분기)
@@ -649,232 +658,6 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
);
}
/// 레트로 스타일 HP 바 (좌우 대칭)
Widget _buildRetroHpBars() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: RetroColors.panelBgOf(context),
border: Border(
bottom: BorderSide(color: RetroColors.borderOf(context), width: 2),
),
),
child: Row(
children: [
// 도전자 HP (좌측, 파란색)
Expanded(
child: _buildRetroHpBar(
name: widget.match.challenger.characterName,
hp: _challengerHp,
hpMax: _challengerHpMax,
fillColor: RetroColors.mpBlue,
accentColor: Colors.blue,
flashAnimation: _challengerFlashAnimation,
hpChange: _challengerHpChange,
isReversed: false,
),
),
// VS 구분자
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'VS',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.goldOf(context),
fontWeight: FontWeight.bold,
),
),
),
// 상대 HP (우측, 빨간색)
Expanded(
child: _buildRetroHpBar(
name: widget.match.opponent.characterName,
hp: _opponentHp,
hpMax: _opponentHpMax,
fillColor: RetroColors.hpRed,
accentColor: Colors.red,
flashAnimation: _opponentFlashAnimation,
hpChange: _opponentHpChange,
isReversed: true,
),
),
],
),
);
}
/// 레트로 세그먼트 HP 바
Widget _buildRetroHpBar({
required String name,
required int hp,
required int hpMax,
required Color fillColor,
required Color accentColor,
required Animation<double> flashAnimation,
required int hpChange,
required bool isReversed,
}) {
final hpRatio = hpMax > 0 ? hp / hpMax : 0.0;
final isLow = hpRatio < 0.2 && hpRatio > 0;
return AnimatedBuilder(
animation: flashAnimation,
builder: (context, child) {
// 플래시 색상 (데미지=빨강)
final isDamage = hpChange < 0;
final flashColor = isDamage
? RetroColors.hpRed.withValues(alpha: flashAnimation.value * 0.4)
: RetroColors.expGreen.withValues(
alpha: flashAnimation.value * 0.4,
);
return Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: flashAnimation.value > 0.1
? flashColor
: accentColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: accentColor, width: 2),
),
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
crossAxisAlignment: isReversed
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
// 이름
Text(
name,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.textPrimaryOf(context),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// HP 세그먼트 바
_buildSegmentBar(
ratio: hpRatio,
fillColor: fillColor,
isLow: isLow,
isReversed: isReversed,
),
const SizedBox(height: 2),
// HP 수치
Row(
mainAxisAlignment: isReversed
? MainAxisAlignment.end
: MainAxisAlignment.start,
children: [
Text(
_hpLabel,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: accentColor.withValues(alpha: 0.8),
),
),
const SizedBox(width: 4),
Text(
'$hp/$hpMax',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: isLow ? RetroColors.hpRed : fillColor,
),
),
],
),
],
),
// 플로팅 데미지 텍스트
if (hpChange != 0 && flashAnimation.value > 0.05)
Positioned(
left: isReversed ? null : 0,
right: isReversed ? 0 : null,
top: -12,
child: Transform.translate(
offset: Offset(0, -12 * (1 - flashAnimation.value)),
child: Opacity(
opacity: flashAnimation.value,
child: Text(
hpChange > 0 ? '+$hpChange' : '$hpChange',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
fontWeight: FontWeight.bold,
color: isDamage
? RetroColors.hpRed
: RetroColors.expGreen,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
Shadow(color: Colors.black, blurRadius: 6),
],
),
),
),
),
),
],
),
);
},
);
}
/// 세그먼트 바 (8-bit 스타일)
Widget _buildSegmentBar({
required double ratio,
required Color fillColor,
required bool isLow,
required bool isReversed,
}) {
const segmentCount = 10;
final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round();
final segments = List.generate(segmentCount, (index) {
final isFilled = isReversed
? index >= segmentCount - filledSegments
: index < filledSegments;
return Expanded(
child: Container(
height: 8,
decoration: BoxDecoration(
color: isFilled
? (isLow ? RetroColors.hpRed : fillColor)
: fillColor.withValues(alpha: 0.2),
border: Border(
right: index < segmentCount - 1
? BorderSide(
color: RetroColors.borderOf(
context,
).withValues(alpha: 0.3),
width: 1,
)
: BorderSide.none,
),
),
),
);
});
return Container(
decoration: BoxDecoration(
border: Border.all(color: RetroColors.borderOf(context), width: 1),
),
child: Row(children: isReversed ? segments.reversed.toList() : segments),
);
}
Widget _buildBattleLog() {
return Container(
margin: const EdgeInsets.all(12),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/l10n/app_localizations.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/features/arena/arena_setup_screen.dart';
@@ -7,12 +8,6 @@ import 'package:asciineverdie/src/features/arena/widgets/arena_rank_card.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/shared/widgets/retro_panel.dart';
// 임시 문자열 (추후 l10n으로 이동)
const _arenaTitle = 'LOCAL ARENA';
const _arenaSubtitle = 'SELECT YOUR FIGHTER';
const _arenaEmpty = 'Not enough heroes';
const _arenaEmptyHint = 'Clear the game with 2+ characters';
/// 로컬 아레나 메인 화면
///
/// 순위표 표시 및 도전하기 버튼
@@ -68,11 +63,12 @@ class _ArenaScreenState extends State<ArenaScreen> {
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
return Scaffold(
backgroundColor: RetroColors.backgroundOf(context),
appBar: AppBar(
title: Text(
_arenaTitle,
l10n.arenaTitle,
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15),
),
centerTitle: true,
@@ -101,6 +97,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
}
Widget _buildEmptyState() {
final l10n = L10n.of(context);
return Center(
child: RetroPanel(
padding: const EdgeInsets.all(24),
@@ -114,7 +111,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
),
const SizedBox(height: 16),
Text(
_arenaEmpty,
l10n.arenaEmptyTitle,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
@@ -123,7 +120,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
),
const SizedBox(height: 8),
Text(
_arenaEmptyHint,
l10n.arenaEmptyHint,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
@@ -143,7 +140,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
return Padding(
padding: const EdgeInsets.all(12),
child: RetroGoldPanel(
title: _arenaSubtitle,
title: L10n.of(context).arenaSelectFighter,
padding: const EdgeInsets.all(8),
child: ListView.builder(
itemCount: rankedEntries.length,

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/engine/arena_service.dart';
import 'package:asciineverdie/src/core/engine/item_service.dart';
import 'package:asciineverdie/src/core/model/arena_match.dart';
@@ -13,11 +14,6 @@ import 'package:asciineverdie/src/features/arena/widgets/arena_idle_preview.dart
import 'package:asciineverdie/src/features/arena/widgets/arena_rank_card.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
// 임시 문자열 (추후 l10n으로 이동)
const _setupTitle = 'ARENA SETUP';
const _selectCharacter = 'SELECT YOUR FIGHTER';
const _startBattleLabel = 'START BATTLE';
/// 아레나 설정 화면
///
/// 캐릭터 선택 및 슬롯 선택
@@ -128,11 +124,12 @@ class _ArenaSetupScreenState extends State<ArenaSetupScreen> {
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
return Scaffold(
backgroundColor: RetroColors.backgroundOf(context),
appBar: AppBar(
title: Text(
_setupTitle,
l10n.arenaSetupTitle,
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 15),
),
centerTitle: true,
@@ -153,7 +150,7 @@ class _ArenaSetupScreenState extends State<ArenaSetupScreen> {
Padding(
padding: const EdgeInsets.all(16),
child: Text(
_selectCharacter,
L10n.of(context).arenaSelectFighter,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
@@ -371,7 +368,7 @@ class _ArenaSetupScreenState extends State<ArenaSetupScreen> {
),
const SizedBox(width: 8),
Text(
_startBattleLabel,
L10n.of(context).arenaStartBattle,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,

View File

@@ -1,18 +1,12 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/engine/item_service.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/shared/retro_colors.dart';
// 임시 문자열 (추후 l10n으로 이동)
const _myEquipmentTitle = 'MY EQUIPMENT';
const _enemyEquipmentTitle = 'ENEMY EQUIPMENT';
const _selectedLabel = 'SELECTED';
const _recommendedLabel = 'BEST';
const _weaponLockedLabel = 'LOCKED';
/// 좌우 대칭 장비 비교 리스트
///
/// 내 장비와 상대 장비를 나란히 표시하고,
@@ -113,6 +107,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
}
Widget _buildHeader(BuildContext context) {
final l10n = L10n.of(context);
return Row(
children: [
// 내 장비 타이틀
@@ -125,7 +120,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
),
child: Text(
_myEquipmentTitle,
l10n.arenaMyEquipment,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
@@ -146,7 +141,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
),
child: Text(
_enemyEquipmentTitle,
l10n.arenaEnemyEquipment,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
@@ -402,7 +397,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
// 잠금 표시 또는 점수 변화
if (isLocked)
Text(
_weaponLockedLabel,
L10n.of(context).arenaWeaponLocked,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
@@ -441,7 +436,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
children: [
if (isRecommended) ...[
Text(
_recommendedLabel,
L10n.of(context).arenaRecommended,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
@@ -471,21 +466,22 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
EquipmentItem? enemyItem,
int scoreDiff,
) {
final l10n = L10n.of(context);
final Color resultColor;
final String resultText;
final IconData resultIcon;
if (scoreDiff > 0) {
resultColor = Colors.green;
resultText = 'You will GAIN +$scoreDiff';
resultText = l10n.arenaScoreGain(scoreDiff);
resultIcon = Icons.arrow_upward;
} else if (scoreDiff < 0) {
resultColor = Colors.red;
resultText = 'You will LOSE $scoreDiff';
resultText = l10n.arenaScoreLose(scoreDiff);
resultIcon = Icons.arrow_downward;
} else {
resultColor = RetroColors.textMutedOf(context);
resultText = 'Even trade';
resultText = l10n.arenaEvenTrade;
resultIcon = Icons.swap_horiz;
}
@@ -563,7 +559,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
),
const SizedBox(width: 6),
Text(
_selectedLabel,
L10n.of(context).arenaSelected,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,

View File

@@ -0,0 +1,254 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 아레나 전투 HP 바 (좌우 대칭 레이아웃)
class ArenaHpBars extends StatelessWidget {
const ArenaHpBars({
super.key,
required this.challengerName,
required this.challengerHp,
required this.challengerHpMax,
required this.challengerFlashAnimation,
required this.challengerHpChange,
required this.opponentName,
required this.opponentHp,
required this.opponentHpMax,
required this.opponentFlashAnimation,
required this.opponentHpChange,
});
final String challengerName;
final int challengerHp;
final int challengerHpMax;
final Animation<double> challengerFlashAnimation;
final int challengerHpChange;
final String opponentName;
final int opponentHp;
final int opponentHpMax;
final Animation<double> opponentFlashAnimation;
final int opponentHpChange;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: RetroColors.panelBgOf(context),
border: Border(
bottom: BorderSide(color: RetroColors.borderOf(context), width: 2),
),
),
child: Row(
children: [
Expanded(
child: _ArenaHpBar(
name: challengerName,
hp: challengerHp,
hpMax: challengerHpMax,
fillColor: RetroColors.mpBlue,
accentColor: Colors.blue,
flashAnimation: challengerFlashAnimation,
hpChange: challengerHpChange,
isReversed: false,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'VS',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.goldOf(context),
fontWeight: FontWeight.bold,
),
),
),
Expanded(
child: _ArenaHpBar(
name: opponentName,
hp: opponentHp,
hpMax: opponentHpMax,
fillColor: RetroColors.hpRed,
accentColor: Colors.red,
flashAnimation: opponentFlashAnimation,
hpChange: opponentHpChange,
isReversed: true,
),
),
],
),
);
}
}
/// 레트로 세그먼트 HP 바 (개별)
class _ArenaHpBar extends StatelessWidget {
const _ArenaHpBar({
required this.name,
required this.hp,
required this.hpMax,
required this.fillColor,
required this.accentColor,
required this.flashAnimation,
required this.hpChange,
required this.isReversed,
});
final String name;
final int hp;
final int hpMax;
final Color fillColor;
final Color accentColor;
final Animation<double> flashAnimation;
final int hpChange;
final bool isReversed;
@override
Widget build(BuildContext context) {
final hpRatio = hpMax > 0 ? hp / hpMax : 0.0;
final isLow = hpRatio < 0.2 && hpRatio > 0;
return AnimatedBuilder(
animation: flashAnimation,
builder: (context, child) {
final isDamage = hpChange < 0;
final flashColor = isDamage
? RetroColors.hpRed.withValues(alpha: flashAnimation.value * 0.4)
: RetroColors.expGreen.withValues(
alpha: flashAnimation.value * 0.4,
);
return Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: flashAnimation.value > 0.1
? flashColor
: accentColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: accentColor, width: 2),
),
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
crossAxisAlignment: isReversed
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.textPrimaryOf(context),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
_buildSegmentBar(context, hpRatio, isLow),
const SizedBox(height: 2),
Row(
mainAxisAlignment: isReversed
? MainAxisAlignment.end
: MainAxisAlignment.start,
children: [
Text(
L10n.of(context).hpLabel,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: accentColor.withValues(alpha: 0.8),
),
),
const SizedBox(width: 4),
Text(
'$hp/$hpMax',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: isLow ? RetroColors.hpRed : fillColor,
),
),
],
),
],
),
if (hpChange != 0 && flashAnimation.value > 0.05)
Positioned(
left: isReversed ? null : 0,
right: isReversed ? 0 : null,
top: -12,
child: Transform.translate(
offset: Offset(0, -12 * (1 - flashAnimation.value)),
child: Opacity(
opacity: flashAnimation.value,
child: Text(
hpChange > 0 ? '+$hpChange' : '$hpChange',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
fontWeight: FontWeight.bold,
color: isDamage
? RetroColors.hpRed
: RetroColors.expGreen,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
Shadow(color: Colors.black, blurRadius: 6),
],
),
),
),
),
),
],
),
);
},
);
}
/// 세그먼트 바 (8-bit 스타일)
Widget _buildSegmentBar(BuildContext context, double ratio, bool isLow) {
const segmentCount = 10;
final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round();
final segments = List.generate(segmentCount, (index) {
final isFilled = isReversed
? index >= segmentCount - filledSegments
: index < filledSegments;
return Expanded(
child: Container(
height: 8,
decoration: BoxDecoration(
color: isFilled
? (isLow ? RetroColors.hpRed : fillColor)
: fillColor.withValues(alpha: 0.2),
border: Border(
right: index < segmentCount - 1
? BorderSide(
color: RetroColors.borderOf(
context,
).withValues(alpha: 0.3),
width: 1,
)
: BorderSide.none,
),
),
),
);
});
return Container(
decoration: BoxDecoration(
border: Border.all(color: RetroColors.borderOf(context), width: 1),
),
child: Row(children: isReversed ? segments.reversed.toList() : segments),
);
}
}

View File

@@ -1,10 +1,10 @@
import 'dart:async';
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
import 'package:asciineverdie/src/core/animation/canvas/ascii_canvas_widget.dart';
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
import 'package:asciineverdie/src/core/animation/character_frames.dart';
import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
import 'package:asciineverdie/src/shared/animation/canvas/ascii_cell.dart';
import 'package:asciineverdie/src/shared/animation/canvas/ascii_canvas_widget.dart';
import 'package:asciineverdie/src/shared/animation/canvas/ascii_layer.dart';
import 'package:asciineverdie/src/shared/animation/character_frames.dart';
import 'package:asciineverdie/src/shared/animation/race_character_frames.dart';
import 'package:flutter/material.dart';
/// 아레나 idle 상태 캐릭터 미리보기 위젯

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/shared/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
@@ -169,7 +170,7 @@ class ArenaRankCard extends StatelessWidget {
),
),
Text(
'SCORE',
L10n.of(context).arenaScore,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/engine/item_service.dart';
import 'package:asciineverdie/src/core/model/arena_match.dart';
import 'package:asciineverdie/src/core/model/equipment_item.dart';
@@ -8,11 +9,6 @@ import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
// 아레나 관련 임시 문자열 (추후 l10n으로 이동)
const _arenaVictory = 'VICTORY!';
const _arenaDefeat = 'DEFEAT...';
const _arenaExchange = 'EQUIPMENT EXCHANGE';
/// 아레나 결과 다이얼로그
///
/// 전투 승패 및 장비 교환 결과 표시
@@ -65,7 +61,7 @@ class ArenaResultDialog extends StatelessWidget {
onPressed: onClose,
style: FilledButton.styleFrom(backgroundColor: resultColor),
child: Text(
l10n.buttonConfirm,
game_l10n.buttonConfirm,
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 13),
),
),
@@ -74,6 +70,7 @@ class ArenaResultDialog extends StatelessWidget {
}
Widget _buildTitle(BuildContext context, bool isVictory, Color color) {
final l10n = L10n.of(context);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -84,7 +81,7 @@ class ArenaResultDialog extends StatelessWidget {
),
const SizedBox(width: 8),
Text(
isVictory ? _arenaVictory : _arenaDefeat,
isVictory ? l10n.arenaVictory : l10n.arenaDefeat,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 15,
@@ -152,7 +149,7 @@ class ArenaResultDialog extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
Text(
isWinner ? 'WINNER' : 'LOSER',
isWinner ? L10n.of(context).arenaWinner : L10n.of(context).arenaLoser,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
@@ -196,7 +193,7 @@ class ArenaResultDialog extends StatelessWidget {
),
const SizedBox(width: 8),
Text(
_arenaExchange,
L10n.of(context).arenaEquipmentExchange,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
@@ -380,17 +377,17 @@ class ArenaResultDialog extends StatelessWidget {
String _getSlotLabel(EquipmentSlot slot) {
return switch (slot) {
EquipmentSlot.weapon => l10n.slotWeapon,
EquipmentSlot.shield => l10n.slotShield,
EquipmentSlot.helm => l10n.slotHelm,
EquipmentSlot.hauberk => l10n.slotHauberk,
EquipmentSlot.brassairts => l10n.slotBrassairts,
EquipmentSlot.vambraces => l10n.slotVambraces,
EquipmentSlot.gauntlets => l10n.slotGauntlets,
EquipmentSlot.gambeson => l10n.slotGambeson,
EquipmentSlot.cuisses => l10n.slotCuisses,
EquipmentSlot.greaves => l10n.slotGreaves,
EquipmentSlot.sollerets => l10n.slotSollerets,
EquipmentSlot.weapon => game_l10n.slotWeapon,
EquipmentSlot.shield => game_l10n.slotShield,
EquipmentSlot.helm => game_l10n.slotHelm,
EquipmentSlot.hauberk => game_l10n.slotHauberk,
EquipmentSlot.brassairts => game_l10n.slotBrassairts,
EquipmentSlot.vambraces => game_l10n.slotVambraces,
EquipmentSlot.gauntlets => game_l10n.slotGauntlets,
EquipmentSlot.gambeson => game_l10n.slotGambeson,
EquipmentSlot.cuisses => game_l10n.slotCuisses,
EquipmentSlot.greaves => game_l10n.slotGreaves,
EquipmentSlot.sollerets => game_l10n.slotSollerets,
};
}

View File

@@ -5,7 +5,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/engine/item_service.dart';
import 'package:asciineverdie/src/core/model/arena_match.dart';
import 'package:asciineverdie/src/core/model/equipment_item.dart';
@@ -15,12 +16,6 @@ import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
// 임시 문자열
const _victory = 'VICTORY!';
const _defeat = 'DEFEAT...';
const _exchange = 'EQUIPMENT EXCHANGE';
const _turns = 'TURNS';
/// 아레나 결과 패널 (인라인)
///
/// 전투 로그 하단에 표시되는 플로팅 결과 패널
@@ -132,7 +127,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${l10n.uiSaved}: $fileName',
'${game_l10n.uiSaved}: $fileName',
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 11),
),
backgroundColor: RetroColors.mpOf(context),
@@ -145,7 +140,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${l10n.uiError}: $e',
'${game_l10n.uiError}: $e',
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 11),
),
backgroundColor: RetroColors.hpOf(context),
@@ -353,6 +348,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
}
Widget _buildTitle(BuildContext context, bool isVictory, Color color) {
final l10n = L10n.of(context);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -363,7 +359,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
),
const SizedBox(width: 8),
Text(
isVictory ? _victory : _defeat,
isVictory ? l10n.arenaVictory : l10n.arenaDefeat,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
@@ -381,67 +377,26 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
}
Widget _buildBattleSummary(BuildContext context) {
final l10n = L10n.of(context);
final winner = widget.result.isVictory
? widget.result.match.challenger.characterName
: widget.result.match.opponent.characterName;
final loser = widget.result.isVictory
? widget.result.match.opponent.characterName
: widget.result.match.challenger.characterName;
final summaryText = l10n.arenaDefeatedIn(winner, loser, widget.turnCount);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 승자
Text(
winner,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.goldOf(context),
),
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
summaryText,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.textSecondaryOf(context),
),
Text(
' defeated ',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.textMutedOf(context),
),
),
// 패자
Text(
loser,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: RetroColors.textSecondaryOf(context),
),
),
Text(
' in ',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.textMutedOf(context),
),
),
// 턴 수
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: RetroColors.goldOf(context).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'${widget.turnCount} $_turns',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: RetroColors.goldOf(context),
),
),
),
],
textAlign: TextAlign.center,
),
);
}
@@ -499,7 +454,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
),
const SizedBox(width: 4),
Text(
_exchange,
L10n.of(context).arenaEquipmentExchange,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
@@ -639,7 +594,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
),
child: Text(
l10n.buttonConfirm,
game_l10n.buttonConfirm,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
@@ -658,7 +613,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
onPressed: _saveBattleLog,
icon: const Icon(Icons.save_alt, size: 14),
label: Text(
l10n.uiSaveBattleLog,
game_l10n.uiSaveBattleLog,
style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 11),
),
style: OutlinedButton.styleFrom(
@@ -681,17 +636,17 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
String _getSlotLabel(EquipmentSlot slot) {
return switch (slot) {
EquipmentSlot.weapon => l10n.slotWeapon,
EquipmentSlot.shield => l10n.slotShield,
EquipmentSlot.helm => l10n.slotHelm,
EquipmentSlot.hauberk => l10n.slotHauberk,
EquipmentSlot.brassairts => l10n.slotBrassairts,
EquipmentSlot.vambraces => l10n.slotVambraces,
EquipmentSlot.gauntlets => l10n.slotGauntlets,
EquipmentSlot.gambeson => l10n.slotGambeson,
EquipmentSlot.cuisses => l10n.slotCuisses,
EquipmentSlot.greaves => l10n.slotGreaves,
EquipmentSlot.sollerets => l10n.slotSollerets,
EquipmentSlot.weapon => game_l10n.slotWeapon,
EquipmentSlot.shield => game_l10n.slotShield,
EquipmentSlot.helm => game_l10n.slotHelm,
EquipmentSlot.hauberk => game_l10n.slotHauberk,
EquipmentSlot.brassairts => game_l10n.slotBrassairts,
EquipmentSlot.vambraces => game_l10n.slotVambraces,
EquipmentSlot.gauntlets => game_l10n.slotGauntlets,
EquipmentSlot.gambeson => game_l10n.slotGambeson,
EquipmentSlot.cuisses => game_l10n.slotCuisses,
EquipmentSlot.greaves => game_l10n.slotGreaves,
EquipmentSlot.sollerets => game_l10n.slotSollerets,
};
}

View File

@@ -2,6 +2,7 @@ import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/l10n/app_localizations.dart';
@@ -17,9 +18,14 @@ class FrontScreen extends StatefulWidget {
this.onLoadSave,
this.onHallOfFame,
this.onLocalArena,
this.onSettings,
this.onPurchaseRemoveAds,
this.onRestorePurchase,
this.hasSaveFile = false,
this.savedGamePreview,
this.hallOfFameCount = 0,
this.isAdRemovalPurchased = false,
this.removeAdsPrice,
this.routeObserver,
this.onRefresh,
});
@@ -36,6 +42,15 @@ class FrontScreen extends StatefulWidget {
/// "Local Arena" 버튼 클릭 시 호출
final void Function(BuildContext context)? onLocalArena;
/// "Settings" 버튼 클릭 시 호출 (언어, 테마, 사운드)
final void Function(BuildContext context)? onSettings;
/// "광고 제거" 구매 버튼 클릭 시 호출
final Future<void> Function(BuildContext context)? onPurchaseRemoveAds;
/// "구매 복원" 버튼 클릭 시 호출
final Future<void> Function(BuildContext context)? onRestorePurchase;
/// 세이브 파일 존재 여부 (새 캐릭터 시 경고용)
final bool hasSaveFile;
@@ -45,6 +60,12 @@ class FrontScreen extends StatefulWidget {
/// 명예의 전당 캐릭터 수 (아레나 활성화 조건: 2명 이상)
final int hallOfFameCount;
/// 광고 제거 구매 여부
final bool isAdRemovalPurchased;
/// 광고 제거 상품 가격 (null이면 스토어 비활성)
final String? removeAdsPrice;
/// RouteObserver (화면 복귀 시 갱신용)
final RouteObserver<ModalRoute<void>>? routeObserver;
@@ -128,8 +149,6 @@ class _FrontScreenState extends State<FrontScreen> with RouteAware {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _RetroHeader(),
const SizedBox(height: 16),
const _AnimationPanel(),
const SizedBox(height: 16),
_ActionButtons(
@@ -147,8 +166,20 @@ class _FrontScreenState extends State<FrontScreen> with RouteAware {
widget.hallOfFameCount >= 2
? () => widget.onLocalArena!(context)
: null,
onSettings: widget.onSettings != null
? () => widget.onSettings!(context)
: null,
onPurchaseRemoveAds:
widget.onPurchaseRemoveAds != null
? () => widget.onPurchaseRemoveAds!(context)
: null,
onRestorePurchase: widget.onRestorePurchase != null
? () => widget.onRestorePurchase!(context)
: null,
savedGamePreview: widget.savedGamePreview,
hallOfFameCount: widget.hallOfFameCount,
isAdRemovalPurchased: widget.isAdRemovalPurchased,
removeAdsPrice: widget.removeAdsPrice,
),
],
),
@@ -165,58 +196,7 @@ class _FrontScreenState extends State<FrontScreen> with RouteAware {
}
}
/// 레트로 스타일 헤더 (타이틀 + 태그)
class _RetroHeader extends StatelessWidget {
const _RetroHeader();
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
return RetroGoldPanel(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Column(
children: [
// 타이틀 (픽셀 폰트)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.auto_awesome, color: RetroColors.gold, size: 20),
const SizedBox(width: 12),
Text(
l10n.appTitle,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 13,
color: RetroColors.gold,
shadows: [
Shadow(color: RetroColors.goldDark, offset: Offset(2, 2)),
],
),
),
],
),
const SizedBox(height: 16),
// 태그 (레트로 스타일)
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
_RetroTag(
icon: Icons.cloud_off_outlined,
label: l10n.tagNoNetwork,
),
_RetroTag(icon: Icons.timer_outlined, label: l10n.tagIdleRpg),
_RetroTag(icon: Icons.storage_rounded, label: l10n.tagLocalSaves),
],
),
],
),
);
}
}
/// 애니메이션 패널
/// 애니메이션 패널 (금색 테두리 + 아이콘+타이틀)
class _AnimationPanel extends StatelessWidget {
const _AnimationPanel();
@@ -231,8 +211,25 @@ class _AnimationPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RetroPanel(
title: 'BATTLE',
return RetroGoldPanel(
titleWidget: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.auto_awesome, color: RetroColors.gold, size: 18),
const SizedBox(width: 10),
Text(
L10n.of(context).appTitle.toUpperCase(),
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.gold,
shadows: [
Shadow(color: RetroColors.goldDark, offset: Offset(2, 2)),
],
),
),
],
),
padding: const EdgeInsets.all(8),
child: AspectRatio(
aspectRatio: _getAspectRatio(),
@@ -249,23 +246,33 @@ class _ActionButtons extends StatelessWidget {
this.onLoadSave,
this.onHallOfFame,
this.onLocalArena,
this.onSettings,
this.onPurchaseRemoveAds,
this.onRestorePurchase,
this.savedGamePreview,
this.hallOfFameCount = 0,
this.isAdRemovalPurchased = false,
this.removeAdsPrice,
});
final VoidCallback? onNewCharacter;
final VoidCallback? onLoadSave;
final VoidCallback? onHallOfFame;
final VoidCallback? onLocalArena;
final VoidCallback? onSettings;
final VoidCallback? onPurchaseRemoveAds;
final VoidCallback? onRestorePurchase;
final SavedGamePreview? savedGamePreview;
final int hallOfFameCount;
final bool isAdRemovalPurchased;
final String? removeAdsPrice;
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
return RetroPanel(
title: 'MENU',
title: L10n.of(context).menuTitle,
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -306,6 +313,31 @@ class _ActionButtons extends StatelessWidget {
onPressed: hallOfFameCount >= 2 ? onLocalArena : null,
isPrimary: false,
),
// 설정
const SizedBox(height: 12),
RetroTextButton(
text: game_l10n.uiSettings,
icon: Icons.settings,
onPressed: onSettings,
isPrimary: false,
),
// IAP 구매 (광고 제거) - 스토어 사용 가능할 때 표시 (구매 완료 시 비활성화)
if (removeAdsPrice != null) ...[
const SizedBox(height: 20),
const Divider(color: RetroColors.panelBorderInner, height: 1),
const SizedBox(height: 12),
_IapPurchaseButton(
price: removeAdsPrice!,
onPurchase: onPurchaseRemoveAds,
onRestore: onRestorePurchase,
enabled: !isAdRemovalPurchased,
),
],
// 이미 구매된 경우 배지 표시
if (isAdRemovalPurchased) ...[
const SizedBox(height: 8),
_PurchasedBadge(),
],
],
),
);
@@ -353,47 +385,331 @@ class _CopyrightFooter extends StatelessWidget {
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
game_l10n.copyrightText,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 7,
color: RetroColors.textDisabled,
child: FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) {
final version = snapshot.data?.version ?? '';
final versionSuffix = version.isNotEmpty ? ' v$version' : '';
return Text(
'${game_l10n.copyrightText}$versionSuffix',
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 7,
color: RetroColors.textDisabled,
),
);
},
),
);
}
}
/// IAP 구매 버튼 (광고 제거)
class _IapPurchaseButton extends StatelessWidget {
const _IapPurchaseButton({
required this.price,
this.onPurchase,
this.onRestore,
this.enabled = true,
});
final String price;
final VoidCallback? onPurchase;
final VoidCallback? onRestore;
final bool enabled;
void _showPurchaseDialog(BuildContext context) {
showDialog<void>(
context: context,
builder: (dialogContext) => _IapPurchaseDialog(
price: price,
onPurchase: () {
Navigator.pop(dialogContext);
onPurchase?.call();
},
),
);
}
@override
Widget build(BuildContext context) {
// 비활성화 상태의 색상
final buttonColor = enabled ? RetroColors.gold : RetroColors.textDisabled;
final bgGradient = enabled
? const LinearGradient(
colors: [Color(0xFF4A3B2A), Color(0xFF3D2E1F)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
)
: const LinearGradient(
colors: [Color(0xFF3A3A3A), Color(0xFF2A2A2A)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 구매 버튼 (클릭 시 팝업)
Container(
decoration: BoxDecoration(
gradient: bgGradient,
border: Border.all(color: buttonColor, width: 2),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: enabled ? () => _showPurchaseDialog(context) : null,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Icon(Icons.block, color: buttonColor, size: 24),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
game_l10n.iapRemoveAds,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: buttonColor,
),
),
const SizedBox(height: 4),
Text(
game_l10n.iapRemoveAdsDesc,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 9,
color: RetroColors.textDisabled,
),
),
],
),
),
// 화살표 아이콘 (상세 보기)
Icon(Icons.arrow_forward_ios, color: buttonColor, size: 16),
],
),
),
),
),
),
const SizedBox(height: 8),
// 복원 버튼
Center(
child: TextButton(
onPressed: enabled ? onRestore : null,
child: Text(
game_l10n.iapRestorePurchase,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 9,
color: RetroColors.textDisabled,
decoration: TextDecoration.underline,
),
),
),
),
],
);
}
}
/// IAP 구매 팝업 다이얼로그
class _IapPurchaseDialog extends StatelessWidget {
const _IapPurchaseDialog({required this.price, this.onPurchase});
final String price;
final VoidCallback? onPurchase;
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: RetroColors.deepBrown,
shape: RoundedRectangleBorder(
side: const BorderSide(color: RetroColors.gold, width: 2),
borderRadius: BorderRadius.circular(4),
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 타이틀
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.star, color: RetroColors.gold, size: 20),
const SizedBox(width: 8),
Text(
game_l10n.iapBenefitTitle,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: RetroColors.gold,
),
),
const SizedBox(width: 8),
const Icon(Icons.star, color: RetroColors.gold, size: 20),
],
),
const SizedBox(height: 20),
// 혜택 목록
_BenefitItem(icon: Icons.block, text: game_l10n.iapBenefit1),
const SizedBox(height: 8),
_BenefitItem(icon: Icons.flash_on, text: game_l10n.iapBenefit2),
const SizedBox(height: 8),
_BenefitItem(icon: Icons.undo, text: game_l10n.iapBenefit3),
const SizedBox(height: 8),
_BenefitItem(icon: Icons.casino, text: game_l10n.iapBenefit4),
const SizedBox(height: 8),
_BenefitItem(icon: Icons.speed, text: game_l10n.iapBenefit5),
const SizedBox(height: 8),
_BenefitItem(
icon: Icons.inventory_2,
text: game_l10n.iapBenefit6,
),
const SizedBox(height: 20),
// 가격 + 구매 버튼
Container(
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF5A4B3A), Color(0xFF4A3B2A)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
border: Border.all(color: RetroColors.gold, width: 2),
borderRadius: BorderRadius.circular(4),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPurchase,
borderRadius: BorderRadius.circular(2),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
game_l10n.iapPurchaseButton,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: RetroColors.gold,
),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: RetroColors.gold,
borderRadius: BorderRadius.circular(4),
),
child: Text(
price,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.deepBrown,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
),
),
const SizedBox(height: 12),
// 취소 버튼
Center(
child: TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
game_l10n.buttonCancel,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: RetroColors.textDisabled,
),
),
),
),
],
),
),
),
);
}
}
/// 레트로 태그 칩
class _RetroTag extends StatelessWidget {
const _RetroTag({required this.icon, required this.label});
/// 혜택 항목 위젯
class _BenefitItem extends StatelessWidget {
const _BenefitItem({required this.icon, required this.text});
final IconData icon;
final String label;
final String text;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: RetroColors.panelBgLight,
border: Border.all(color: RetroColors.panelBorderInner, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: RetroColors.gold, size: 12),
const SizedBox(width: 6),
Text(
label,
return Row(
children: [
Icon(icon, color: RetroColors.expGreen, size: 18),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
fontSize: 10,
color: RetroColors.textLight,
),
),
),
],
);
}
}
/// 이미 구매됨 뱃지
class _PurchasedBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: RetroColors.panelBgLight,
border: Border.all(color: RetroColors.expGreen, width: 2),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check_circle, color: RetroColors.expGreen, size: 20),
const SizedBox(width: 8),
Text(
game_l10n.iapAlreadyPurchased,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 11,
color: RetroColors.expGreen,
),
),
],
),
);

Some files were not shown because too many files have changed in this diff Show More